master
Drew Owens Publish 0.80.0 eb7d34d
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620
#!/usr/bin/env node
// Server-only tests for tags (0.59/0.60) and the tag registry (0.61): no browser. Spawns server.js --no-auth on
// ports 8940-8949 against temp copies of a small plan and exercises the contract
// over HTTP. Exits non-zero on any failure.
//
//   node test/server_test.mjs
//   node test/server_test.mjs --only clickup   only the sections whose name contains a term
//                                              (comma-separated): "core" is everything that
//                                              predates the ClickUp import, "clickup import" it
import { spawn, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVER = path.join(HERE, '..', 'server.js');
const ARGV = process.argv.slice(2);
const ONLY = ARGV.includes('--only') ? String(ARGV[ARGV.indexOf('--only') + 1] || '').split(',').filter(Boolean) : null;
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-srv-test-'));
const procs = [];
let pass = 0, fail = 0;

function ok(cond, msg, extra) {
  if (cond) { pass++; console.log('  ok   ' + msg); }
  else { fail++; console.log('  FAIL ' + msg + (extra !== undefined ? '  :: ' + JSON.stringify(extra).slice(0, 400) : '')); }
}
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);

function basePlan(withTags) {
  const p = {
    project: { title: 'Tag test', subtitle: 'server_test' },
    phases: [
      { name: 'Tech · Fab', start: '2026-10', end: '2026-11' },
      { name: 'Ops', start: '2026-10', end: '2026-12' },
    ],
    items: [
      { type: 'task', id: 'etch', name: 'Etch the cards', start: '2026-10-05', end: '2026-10-09', phase: 0 },
      { type: 'task', id: 'ship', name: 'Ship', start: '2026-10-12', end: '2026-10-16', phase: 1, deps: ['etch'] },
      { type: 'milestone', name: 'Launch', date: '2026-10-20', phase: 1 },
    ],
  };
  if (withTags) {
    p.phases[0].tags = ['Tech', 'tech', '  '];
    p.items[0].tags = ['long-lead'];
    p.items[2].tags = ['gate'];
  }
  return p;
}

async function portFree(port) {
  return new Promise(res => {
    const s = net.createServer();
    s.once('error', () => res(false));
    s.once('listening', () => s.close(() => res(true)));
    s.listen(port, '127.0.0.1');
  });
}
async function freePort(from) {
  for (let p = from; p <= 8949; p++) if (await portFree(p)) return p;
  throw new Error('no free port in 8940-8949');
}

async function start(name, plan, sidecar, fromPort, extraEnv, opts = {}) {
  const dir = path.join(TMP, name);
  fs.mkdirSync(dir, { recursive: true });
  const data = path.join(dir, 'plan.json');
  fs.writeFileSync(data, JSON.stringify(plan, null, 2) + '\n');
  if (sidecar) fs.writeFileSync(path.join(dir, 'plan-state.json'), JSON.stringify(sidecar));
  const port = await freePort(fromPort);
  const argv = [SERVER, '--data', data, '--port', String(port)].concat(opts.auth ? [] : ['--no-auth'], opts.args || []);
  const p = spawn(process.execPath, argv, { stdio: ['ignore', 'pipe', 'pipe'], env: Object.assign({}, process.env, extraEnv || {}) });
  let log = '';
  p.stdout.on('data', d => { log += d; });
  p.stderr.on('data', d => { log += d; });
  procs.push(p);
  const base = `http://127.0.0.1:${port}`;
  for (let i = 0; i < 100; i++) {
    try { const r = await fetch(base + '/health'); if (r.ok) break; } catch (e) {}
    await new Promise(r => setTimeout(r, 100));
    if (i === 99) throw new Error('server did not start: ' + log);
  }
  const token = fs.readFileSync(path.join(dir, 'plan-agent-token'), 'utf8').trim();
  return { base, dir, data, port, proc: p, token, log: () => log };
}
function stop(s) { try { s.proc.kill('SIGTERM'); } catch (e) {} }

const getJ = async (s, u) => (await fetch(s.base + u)).json();
const getT = async (s, u) => (await fetch(s.base + u)).text();
async function post(s, u, body, headers = {}) {
  const r = await fetch(s.base + u, { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, headers),
    body: typeof body === 'string' ? body : JSON.stringify(body) });
  let j = null; const t = await r.text(); try { j = JSON.parse(t); } catch (e) {}
  return { status: r.status, j, t };
}
const byId = (d, id) => d.items.find(r => r.id === id);
const byName = (d, n) => d.items.find(r => r.name === n);

async function main() {
  // ── A: untagged plan with an OLD (v3) sidecar ──
  console.log('A. untagged plan, v3 sidecar');
  const v3 = { v: 3, tasks: [{ id: 'etch', s: '2026-10-06', e: '2026-10-09', owner: 'Drew' }],
    milestones: [], phases: [{ i: 1, start: '2026-10', end: '2027-01' }], order: [], deleted: [], added: [] };
  const A = await start('a', basePlan(false), v3, 8940);
  let ex = await getJ(A, '/export/json');
  let exText = await getT(A, '/export/json');
  ok(byId(ex, 'etch').owner === 'Drew' && byId(ex, 'etch').start === '2026-10-06', 'old v3 sidecar still loads (owner, dates)');
  ok(ex.phases[1].end === '2027-01', 'old v3 phase patch still applies');
  ok(!exText.includes('"tags"'), 'no tags key appears in /export/json of an untagged plan');
  const st = await getJ(A, '/state');
  ok(st.v === 4 && !JSON.stringify(st).includes('"tags"'), 'sidecar upgraded to v4 with no tags keys');
  const fileBefore = fs.readFileSync(A.data, 'utf8');
  ok((await getT(A, '/data')) === fileBefore, '/data of an untagged file is byte for byte the file');
  await getT(A, '/export/csv'); await getT(A, '/export/html');
  ok(fs.readFileSync(A.data, 'utf8') === fileBefore, 'exports leave the data file untouched');
  const etchUid = byId(ex, 'etch').uid, shipUid = byId(ex, 'ship').uid, msUid = byName(ex, 'Launch').uid;

  // Normalisation through /save (sidecar date-patch phase shape), via the agent token.
  const bad = await post(A, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] }, { Authorization: 'Bearer deadbeef' });
  ok(bad.status === 401, '/save with a wrong agent token is 401', bad.status);
  const many = Array.from({ length: 15 }, (_, i) => 't' + i);
  const messy = ['  long-lead ', 'Long-Lead', '', '   ', 42, null, { a: 1 }, 'x'.repeat(40), 'LONG-LEAD'];
  const save1 = await post(A, '/save', {
    v: 4, baseRev: st.stateRev,
    items: [{ uid: etchUid, s: '2026-10-06', e: '2026-10-09', owner: 'Drew', tags: messy },
            { uid: shipUid, tags: many }, { uid: msUid, tags: 'not-a-list' }],
    phases: [{ i: 0, start: '2026-10', end: '2026-11', tags: [' Tech ', 'tech', 'Fab'] }, { i: 1, start: '2026-10', end: '2027-01', tags: { x: 1 } }],
    order: [], deleted: [], added: [],
  }, { Authorization: 'Bearer ' + A.token });
  ok(save1.status === 200, '/save with the agent token and baseRev is 200', save1);
  ex = await getJ(A, '/export/json');
  ok(eq(byId(ex, 'etch').tags, ['long-lead', 'x'.repeat(32)]), 'trim, case-insensitive dedupe (first spelling), non-strings dropped, 32-char cap', byId(ex, 'etch').tags);
  ok(eq(byId(ex, 'ship').tags, many.slice(0, 12)), 'list capped at 12', byId(ex, 'ship').tags);
  ok(!('tags' in byName(ex, 'Launch')), 'malformed row tags in /save dropped, not stored, not a 500');
  ok(eq(ex.phases[0].tags, ['Tech', 'Fab']), 'phase tags from a date-patch sidecar entry, normalised', ex.phases[0]);
  ok(!('tags' in ex.phases[1]), 'malformed phase tags dropped');
  const st2 = await getJ(A, '/state');
  const sEtch = st2.items.find(x => x.uid === etchUid), sMs = st2.items.find(x => x.uid === msUid);
  ok(eq(sEtch.tags, ['long-lead', 'x'.repeat(32)]) && !('tags' in sMs), 'sidecar stores normalised tags, drops malformed', st2.items);
  const rawSide = JSON.parse(fs.readFileSync(path.join(A.dir, 'plan-state.json'), 'utf8'));
  ok(eq(rawSide.phases[0].tags, ['Tech', 'Fab']) && !('tags' in rawSide.phases[1]), 'sidecar file on disk normalised');
  // A body that is broken in a different place entirely still does not 500.
  const garbage = await post(A, '/save', { v: 4, items: [null, 5, { uid: etchUid, tags: [[], 7] }], phases: [null, { i: 0, tags: null }] });
  ok(garbage.status === 200, 'odd /save body (null entries, nested junk) is not a 500', garbage.status);
  ex = await getJ(A, '/export/json');
  ok(!('tags' in byId(ex, 'etch')), 'a tag list of only non-strings normalises to [] (cleared)', byId(ex, 'etch'));
  // `;` is the CSV separator, so it cannot be inside a tag (0.60.0 review): it becomes a
  // space, and the CSV then round-trips the list it was given.
  await post(A, '/save', { v: 4, items: [{ uid: etchUid, tags: ['x;y', 'c;', ';'] }], phases: [], order: [], deleted: [], added: [] });
  ex = await getJ(A, '/export/json');
  const semiCsv = (await getT(A, '/export/csv')).split('\n').find(l => l.includes(',etch,')) || '';
  ok(eq(byId(ex, 'etch').tags, ['x y', 'c']) && semiCsv.includes(',x y;c,'), 'a ";" inside a tag becomes a space, so the CSV keeps one tag as one', { tags: byId(ex, 'etch').tags, semiCsv });
  // put tags back for the rest
  await post(A, '/save', { v: 4, items: [{ uid: etchUid, tags: ['long-lead', 'Etch'] }, { uid: msUid, tags: ['gate'] }],
    phases: [{ i: 0, start: '2026-10', end: '2026-11', tags: ['Tech'] }], order: [], deleted: [], added: [] });
  ex = await getJ(A, '/export/json');
  ok(eq(byId(ex, 'etch').tags, ['long-lead', 'Etch']) && eq(byName(ex, 'Launch').tags, ['gate']), 'task and milestone tags merged into /export/json');

  // HTML snapshot and CSV export
  const html = await getT(A, '/export/html');
  ok(html.includes('"tags":["long-lead","Etch"]') && html.includes('"tags":["Tech"]'), 'HTML snapshot inlines row and section tags');
  const csv = await getT(A, '/export/csv');
  const hdr = csv.split('\n')[0].split(',');
  ok(hdr.includes('tags') && hdr.indexOf('tags') === hdr.indexOf('deps') + 1, 'CSV has a tags column after deps', hdr);
  const etchLine = csv.split('\n').find(l => l.includes('Etch the cards'));
  ok(etchLine && etchLine.split(',')[hdr.indexOf('tags')] === 'long-lead;Etch', 'CSV tags joined with ;', etchLine);

  // Changelog: two new actions, via the agent token, rendered in md.
  const lg = await post(A, '/log', { events: [
    { action: 'tags', uid: etchUid, id: 'etch', name: 'Etch the cards', from: { tags: [] }, to: { tags: ['long-lead'] }, via: 'editor' },
    { action: 'tags', uid: etchUid, id: 'etch', name: 'Etch the cards', from: { tags: ['long-lead'] }, to: { tags: ['long-lead', 'Etch'] }, via: 'table-edit' },
    { action: 'tags', uid: msUid, name: 'Launch', from: { tags: ['gate'] }, to: { tags: [] }, via: 'editor' },
    { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: [] }, to: { tags: ['Tech'] }, via: 'setup' },
    { action: 'phase.tags', phase: 1, from: 'junk', to: { tags: 'Tech' } },
    { action: 'tags', to: { tags: [1, { a: 2 }, 'ok\nline'] }, extra: { weird: true } },
    // Worded by direction (0.59.0 review): a removal, a swap, and a change of case only.
    { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['Tech', 'long-lead'] }, to: { tags: ['Tech'] }, via: 'setup' },
    { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['Tech', 'Launch'] }, to: { tags: ['Tech', 'Go'] }, via: 'setup' },
    { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['tech'] }, to: { tags: ['Tech'] }, via: 'setup' },
  ] }, { Authorization: 'Bearer ' + A.token, 'X-Ignore': '1' });
  ok(lg.status === 200 && lg.j.added === 9, '/log accepts tags and phase.tags with the agent token', lg);
  const mdRes = await fetch(A.base + '/changelog?format=md');
  const md = await mdRes.text();
  ok(mdRes.status === 200, 'changelog md renders 200 with malformed tag events');
  const need = [
    'adom-gantt agent tagged "Etch the cards" with long-lead (was none) (editor)',
    'adom-gantt agent tagged "Etch the cards" with long-lead, Etch (was long-lead) (table-edit)',
    'adom-gantt agent cleared the tags on "Launch" (was gate) (editor)',
    'adom-gantt agent tagged section "Tech · Fab" with Tech (was none) (setup)',
    'adom-gantt agent cleared the tags on section #2 (was none)',
    'adom-gantt agent tagged an item with ok line (was none)',
    'adom-gantt agent untagged section "Tech · Fab": long-lead (now Tech) (setup)',
    'adom-gantt agent retagged section "Tech · Fab": +Go, -Launch (now Tech, Go) (setup)',
    'adom-gantt agent retagged section "Tech · Fab" as Tech (was tech) (setup)',
  ];
  ok(!/tagged section "Tech · Fab" with Tech \(was Tech, long-lead\)/.test(md), 'a removal no longer reads like an addition');
  need.forEach(n => ok(md.includes(n), 'md line: ' + n, md.split('\n').filter(l => /tag/.test(l))));
  const health = await getJ(A, '/health');
  ok(!health.integrity || health.integrity.ok !== false, 'hash chain still whole after the writes', health.integrity);
  const stNow = await getJ(A, '/state');
  ok(typeof health.stateRev === 'number' && health.stateRev === stNow.stateRev, '/health reports the plan\'s stateRev (0.75.1), the revision every tab saves against', [health.stateRev, stNow.stateRev]);

  // CSV round trip WITH the column: export -> import -> export
  const exBefore = await getJ(A, '/export/json');
  let imp = await post(A, '/import/csv', csv, { 'Content-Type': 'text/csv', Authorization: 'Bearer ' + A.token });
  ok(imp.status === 200, '/import/csv of our own export is 200', imp);
  let after = await getJ(A, '/export/json');
  ok(eq(byId(after, 'etch').tags, byId(exBefore, 'etch').tags) && eq(byName(after, 'Launch').tags, ['gate']) && !('tags' in byId(after, 'ship')),
    'CSV round trip keeps row tags; untagged rows get no key', after.items);
  ok(after.phases.every(p => !('tags' in p)), 'CSV carries no section tags');
  // CSV with messy tags
  const csv2 = 'name,start,end,phase,Tags\nA,2026-10-05,2026-10-09,P, a ; A ;b;;\nB,2026-10-05,2026-10-09,P,\n';
  imp = await post(A, '/import/csv', csv2, { 'Content-Type': 'text/csv' });
  after = await getJ(A, '/export/json');
  ok(imp.status === 200 && eq(byName(after, 'A').tags, ['a', 'b']) && !('tags' in byName(after, 'B')), 'CSV import splits on ; and normalises', after.items);
  // CSV without the column
  const csv3 = 'name,start,end,phase\nA,2026-10-05,2026-10-09,P\n';
  imp = await post(A, '/import/csv', csv3, { 'Content-Type': 'text/csv' });
  after = await getJ(A, '/export/json');
  ok(imp.status === 200 && !(await getT(A, '/export/json')).includes('"tags"'), 'CSV without a tags column imports as before');

  // JSON import normalises, and an untagged file round-trips unchanged
  const jp = basePlan(false);
  jp.phases[0].tags = [' Tech', 'TECH', 9];
  jp.phases[1].tags = [];
  jp.items[0].tags = ['a', 'A', 'b '];
  jp.items[1].tags = 'bad';
  imp = await post(A, '/import/json', jp);
  after = await getJ(A, '/export/json');
  ok(imp.status === 200 && eq(after.phases[0].tags, ['Tech']) && !('tags' in after.phases[1]), 'JSON import normalises phase tags; [] becomes no key', after.phases);
  ok(eq(byId(after, 'etch').tags, ['a', 'b']) && !('tags' in byId(after, 'ship')), 'JSON import normalises item tags; malformed dropped');
  const onDisk = JSON.parse(fs.readFileSync(A.data, 'utf8'));
  ok(!('tags' in onDisk.phases[1]) && !('tags' in onDisk.items[1]), 'imported data file stores no empty or malformed tags');
  // untagged JSON round trip
  const clean = basePlan(false);
  imp = await post(A, '/import/json', clean);
  const out1 = await getJ(A, '/export/json');
  imp = await post(A, '/import/json', out1);
  const out2 = await getJ(A, '/export/json');
  ok(eq(out1, out2) && !JSON.stringify(out2).includes('"tags"'), 'untagged JSON export -> import -> export is identical, no tags keys');
  // exported JSON round trip with tags
  const tp = basePlan(true);
  await post(A, '/import/json', tp);
  const t1 = await getJ(A, '/export/json');
  await post(A, '/import/json', t1);
  const t2 = await getJ(A, '/export/json');
  ok(eq(t1, t2) && eq(t2.phases[0].tags, ['Tech']) && eq(byId(t2, 'etch').tags, ['long-lead']), 'tagged JSON export -> import -> export is identical');

  // Jira Labels
  const jira = 'Summary,Issue key,Status,Created,Due Date,Labels\nFix it,GT-1,To Do,2026-10-01,2026-10-05,backend  Urgent urgent\nNo labels,GT-2,Done,2026-10-01,2026-10-03,\n';
  imp = await post(A, '/import/jira-csv', jira, { 'Content-Type': 'text/csv' });
  after = await getJ(A, '/export/json');
  ok(imp.status === 200 && eq(byName(after, 'Fix it').tags, ['backend', 'Urgent']) && !('tags' in byName(after, 'No labels')), 'Jira Labels map to tags', after.items);
  // MS Project: no tags
  const xml = '<Project><Tasks><Task><UID>1</UID><Name>X</Name><Start>2026-10-01T08:00:00</Start><Finish>2026-10-05T17:00:00</Finish></Task></Tasks></Project>';
  imp = await post(A, '/import/msproject-xml', xml, { 'Content-Type': 'application/xml' });
  ok(imp.status === 200 && !(await getT(A, '/export/json')).includes('"tags"'), 'MS Project import carries no tags', imp);
  stop(A);

  // ── B: tagged data file, full-shape sidecar written by a client that predates tags ──
  console.log('B. tagged data file, full-shape sidecar');
  const B = await start('b', basePlan(true), null, 8941);
  const d0 = await getJ(B, '/data');
  ok(eq(d0.phases[0].tags, ['Tech']), '/data normalises a hand-edited tag list', d0.phases[0]);
  ex = await getJ(B, '/export/json');
  const eU = byId(ex, 'etch').uid;
  // old client: full phase list with names, no tags keys; item override without tags
  let r = await post(B, '/save', { v: 4, items: [{ uid: eU, owner: 'Drew' }],
    phases: ex.phases.slice().reverse().map((p, i) => ({ i, name: p.name, start: p.start, end: p.end, uid: p.uid })), order: [], deleted: [], added: [] });
  ex = await getJ(B, '/export/json');
  ok(r.status === 200 && ex.phases[0].name === 'Ops' && eq(ex.phases[1].tags, ['Tech']) && !('tags' in ex.phases[0]),
    'absent tags in a full-shape (reordered) sidecar: the data file tags stand, by section', ex.phases);
  ok(eq(byId(ex, 'etch').tags, ['long-lead']) && byId(ex, 'etch').owner === 'Drew', 'absent tags on an item override: data file tags stand');
  // explicit [] clears
  r = await post(B, '/save', { v: 4, items: [{ uid: eU, tags: [] }],
    phases: ex.phases.map((p, i) => ({ i, name: p.name, start: p.start, end: p.end, uid: p.uid, tags: [] })), order: [], deleted: [], added: [] });
  ex = await getJ(B, '/export/json');
  ok(!('tags' in byId(ex, 'etch')) && ex.phases.every(p => !('tags' in p)), 'explicit [] in the sidecar clears the data file tags', ex);
  ok(eq(byName(ex, 'Launch').tags, ['gate']), 'untouched milestone keeps its data file tags');
  // a created row (sidecar `added`) with messy tags
  r = await post(B, '/save', { v: 4, items: [], phases: [], order: [], deleted: [],
    added: [{ type: 'task', uid: '00900', id: 'new', name: 'New one', start: '2026-10-05', end: '2026-10-06', phase: 0, tags: [' n ', 'N', ''] },
            { type: 'task', uid: '00901', id: 'new2', name: 'New two', start: '2026-10-05', end: '2026-10-06', phase: 0, tags: [] }] });
  ex = await getJ(B, '/export/json');
  ok(eq(byId(ex, 'new').tags, ['n']) && !('tags' in byId(ex, 'new2')), 'created rows carry normalised tags; empty means no key', [byId(ex, 'new'), byId(ex, 'new2')]);
  // compact bakes the merged tags into the data file (the last /save carried no
  // overrides, since a save is whole-state, so the data file's own tags stand again)
  r = await post(B, '/compact', {}, { Authorization: 'Bearer ' + B.token });
  const baked = JSON.parse(fs.readFileSync(B.data, 'utf8'));
  ok(r.status === 200 && eq(byId(baked, 'new').tags, ['n']) && eq(byName(baked, 'Launch').tags, ['gate']) && eq(byId(baked, 'etch').tags, ['long-lead']) && eq(baked.phases[0].tags, ['Tech']),
    '/compact bakes merged tags into the data file', r);
  stop(B);

  // ── C: a full sidecar section list LONGER than the data file's (0.58.0 review, V-A) ──
  // Sections added in Setup live only in the sidecar. A row saved into one of them, or into
  // a data-file section moved down past them, has a phase index beyond the data file's
  // list. Until 0.58.0 the merge checked that index against the data file's list, failed
  // it, and left the row in whatever section held its OLD index.
  console.log('C. sidecar with more sections than the data file');
  const C = await start('c', basePlan(false), null, 8940);
  ex = await getJ(C, '/export/json');
  const cE = byId(ex, 'etch').uid, cS = byId(ex, 'ship').uid, cM = byName(ex, 'Launch').uid;
  // Order: Added, Ops, Tech · Fab. Etch (data-file phase 0) is saved at index 2, past the
  // data file's two sections; Ship and Launch (data-file phase 1) at index 1.
  r = await post(C, '/save', { v: 4, items: [{ uid: cE, phase: 2 }, { uid: cS, phase: 1 }, { uid: cM, phase: 1 }],
    phases: [{ i: 0, name: 'Added', start: '', end: '' }, { i: 1, name: 'Ops', start: '2026-10', end: '2026-12' }, { i: 2, name: 'Tech · Fab', start: '2026-10', end: '2026-11' }],
    order: [], deleted: [], added: [] });
  ex = await getJ(C, '/export/json');
  const secOf = n => ex.phases[byName(ex, n).phase]?.name;
  ok(r.status === 200 && secOf('Etch the cards') === 'Tech · Fab' && secOf('Ship') === 'Ops' && secOf('Launch') === 'Ops',
    'a row saved into a section past the data file\'s list stays in it', ex.items.map(x => [x.name, x.phase]));
  // An index past the SIDECAR's list is still refused, and the row keeps its data-file one.
  r = await post(C, '/save', { v: 4, items: [{ uid: cE, phase: 7 }, { uid: cS, phase: -1 }],
    phases: [{ i: 0, name: 'Added', start: '', end: '' }, { i: 1, name: 'Ops', start: '2026-10', end: '2026-12' }, { i: 2, name: 'Tech · Fab', start: '2026-10', end: '2026-11' }],
    order: [], deleted: [], added: [] });
  ex = await getJ(C, '/export/json');
  ok(byId(ex, 'etch').phase === 0 && byId(ex, 'ship').phase === 1, 'a phase index outside the sidecar\'s list is ignored', ex.items.map(x => [x.name, x.phase]));
  stop(C);

  // ── D: the tag registry, project.tags (0.61.0) ──
  console.log('D. the tag registry');
  // Absent stays absent: an old data file and an old sidecar load as before, with no key.
  const D = await start('d', basePlan(true), null, 8940);
  ex = await getJ(D, '/export/json');
  ok(!('tags' in ex.project), 'a plan that never used the registry exports no project.tags');
  await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] });
  ex = await getJ(D, '/export/json');
  ok(!('tags' in ex.project), 'a sidecar with no tags key leaves the registry absent');
  // Normalisation through /save: names by the normalizeTags rules, colours to lowercase
  // #rrggbb, junk dropped, unique without regard to case (first spelling wins).
  r = await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [],
    tags: [{ name: ' Tech ', color: '#8C6BF7' }, { name: 'tech', color: '#000000' }, { name: 'Ops', color: 'E8B' }, { name: 'x;y', color: 'blue' },
      'bare', { name: 'z'.repeat(40) }, { name: '' }, null, 7, { color: '#ffffff' }, { name: 'Gate', color: '#12345' }] });
  ex = await getJ(D, '/export/json');
  const wantReg = [{ name: 'Tech', color: '#8c6bf7' }, { name: 'Ops', color: '#ee88bb' }, { name: 'x y' }, { name: 'bare' }, { name: 'z'.repeat(32) }, { name: 'Gate' }];
  ok(r.status === 200 && eq(ex.project.tags, wantReg), 'the sidecar registry is normalised and merged into project.tags', ex.project.tags);
  const rawD = JSON.parse(fs.readFileSync(path.join(D.dir, 'plan-state.json'), 'utf8'));
  ok(eq(rawD.tags, wantReg), 'the sidecar file on disk holds the normalised registry', rawD.tags);
  ok(eq(ex.phases[0].tags, ['Tech']) && eq(byId(ex, 'etch').tags, ['long-lead']), 'section and row tags are untouched by the registry');
  const htmlD = await getT(D, '/export/html');
  ok(htmlD.includes('"color":"#8c6bf7"'), 'the HTML snapshot carries project.tags');
  // [] empties it: the merged plan has no key, as if it had never had one.
  await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: [] });
  ex = await getJ(D, '/export/json');
  ok(!('tags' in ex.project), 'an emptied registry ([]) exports no project.tags key');
  // A malformed registry is dropped, never stored, never a 500.
  r = await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: 'nope' });
  ok(r.status === 200 && !('tags' in JSON.parse(fs.readFileSync(path.join(D.dir, 'plan-state.json'), 'utf8'))), 'a registry that is not a list is dropped');
  // /compact folds the registry into the data file; then the data file's stands.
  await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }] });
  r = await post(D, '/compact', {});
  const onDiskD = JSON.parse(fs.readFileSync(D.data, 'utf8'));
  ok(r.status === 200 && eq(onDiskD.project.tags, [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }]), '/compact writes project.tags into the data file', onDiskD.project);
  ex = await getJ(D, '/export/json');
  ok(eq(ex.project.tags, [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }]), 'with no sidecar the data file\'s registry stands');
  const dataD = await getJ(D, '/data');
  ok(eq(dataD.project.tags, ex.project.tags), '/data serves the registry normalised');
  // Round trip through /import/json, normalised on the way in.
  const jr = basePlan(false);
  jr.project.tags = [{ name: 'Risk', color: '#FF8A7A' }, { name: 'risk' }, { name: 5 }, { name: 'Empty colour', color: '' }];
  r = await post(D, '/import/json', jr);
  ex = await getJ(D, '/export/json');
  ok(r.status === 200 && eq(ex.project.tags, [{ name: 'Risk', color: '#ff8a7a' }, { name: 'Empty colour' }]), '/import/json reads and normalises project.tags', ex.project.tags);
  const back = await getJ(D, '/export/json');
  r = await post(D, '/import/json', back);
  ok(r.status === 200 && eq((await getJ(D, '/export/json')).project.tags, back.project.tags), 'an export imports back with the same registry');
  const jn = basePlan(false); jn.project.tags = [];
  await post(D, '/import/json', jn);
  ok(!('tags' in JSON.parse(fs.readFileSync(D.data, 'utf8')).project), 'an imported empty registry is stored as no key');
  // The changelog.
  const lgD = await post(D, '/log', { actor: 'user', events: [
    { action: 'tag.create', tag: 'someday', to: { color: '' }, via: 'setup' },
    { action: 'tag.create', tag: 'Risk', to: { color: '#ff8a7a' }, via: 'setup' },
    { action: 'tag.color', tag: 'Tech', from: { color: '' }, to: { color: '#8c6bf7' }, via: 'setup' },
    { action: 'tag.color', tag: 'Tech', from: { color: '#8c6bf7' }, to: { color: '#00b8b1' }, via: 'redo' },
    { action: 'tag.color', tag: 'Tech', from: { color: '#00b8b1' }, to: { color: '' }, via: 'setup' },
    { action: 'tag.rename', tag: 'later', from: { name: 'someday' }, to: { name: 'later' }, via: 'setup' },
    { action: 'tag.delete', tag: 'later', from: { color: '' }, via: 'undo' },
    { action: 'tag.color', tag: 'evil\nline', from: 5, to: { color: ['x'] } },
  ] });
  ok(lgD.status === 200 && lgD.j.added === 8, '/log accepts the tag.* actions', lgD);
  const mdD = await getT(D, '/changelog?format=md');
  const linesD = ['user created the tag "someday" (setup)', 'user created the tag "Risk" in #ff8a7a (setup)', 'user coloured the tag "Tech" #8c6bf7 (setup)',
    'user recoloured the tag "Tech" #8c6bf7 -> #00b8b1 (redo)', 'user cleared the colour on the tag "Tech" (was #00b8b1) (setup)',
    'user renamed the tag "someday" to "later" (setup)', 'user removed the tag "later" from the plan\'s tag list (undo)', 'user cleared the colour on the tag "evil line"'];
  const missingD = linesD.filter(l => !mdD.includes(l));
  ok(missingD.length === 0, 'the markdown changelog reads each tag.* event as a sentence, junk fields included', missingD);
  stop(D);

  // ── Effort (0.63.0): estimated hours per task ──
  const ep = basePlan(false);
  ep.items[0].effort = '12.25'; ep.items[1].effort = -3; ep.items[2].effort = 40;
  const E = await start('effort', ep, null, 8946);
  let ee = await getJ(E, '/export/json');
  ok(byId(ee, 'etch').effort === 12.3, 'a data-file effort is normalised: a numeric string, to one decimal', byId(ee, 'etch'));
  ok(!('effort' in byId(ee, 'ship')), 'a negative effort is dropped', byId(ee, 'ship'));
  ok(!('effort' in byName(ee, 'Launch')), 'a milestone carries no effort', byName(ee, 'Launch'));
  const N0 = await start('noeffort', basePlan(false), null, 8947);
  ok(!JSON.stringify(await getJ(N0, '/export/json')).includes('"effort"'), 'a plan that never used effort exports no effort key');
  stop(N0);
  const est = await getJ(E, '/state');
  const eu = id => byId(ee, id).uid;
  const es = await post(E, '/save', { v: 4, baseRev: est.stateRev,
    items: [{ uid: eu('etch'), effort: null }, { uid: eu('ship'), effort: 'x' }], phases: [], order: [], deleted: [],
    added: [{ type: 'task', uid: '00090', id: 'new1', name: 'New one', start: '2026-10-05', end: '2026-10-06', phase: 0, effort: 6 }] }, { Authorization: 'Bearer ' + E.token });
  ok(es.status === 200, '/save with effort fields is 200 (a junk value is dropped, not a 500)', es);
  ee = await getJ(E, '/export/json');
  ok(!('effort' in byId(ee, 'etch')), 'null in the sidecar clears a data-file effort', byId(ee, 'etch'));
  ok(!('effort' in byId(ee, 'ship')), 'a junk sidecar effort leaves the data file\'s (none) standing', byId(ee, 'ship'));
  ok(byId(ee, 'new1') && byId(ee, 'new1').effort === 6, 'a created row keeps its effort', byId(ee, 'new1'));
  const ecsv = await getT(E, '/export/csv');
  const eh = ecsv.split('\n')[0].split(','), ei = eh.indexOf('effort');
  ok(ei > 0 && ecsv.split('\n').find(l => l.includes(',new1,')).split(',')[ei] === '6', 'the CSV has an effort column', eh);
  const imp2 = await post(E, '/import/csv', 'name,start,end,date,phase,effort,type\nA,2026-10-05,2026-10-09,,Tech · Fab,2.5,task\nB,2026-10-05,2026-10-09,,Tech · Fab,abc,task\nC,,,2026-10-20,Tech · Fab,8,milestone\n', { 'Content-Type': 'text/csv', Authorization: 'Bearer ' + E.token });
  ee = await getJ(E, '/export/json');
  ok(imp2.status === 200 && byName(ee, 'A').effort === 2.5 && !('effort' in byName(ee, 'B')) && !('effort' in byName(ee, 'C')), 'CSV import reads effort, drops junk, and gives a milestone none', [imp2.status, imp2.t && imp2.t.slice(0, 200), byName(ee, 'A'), byName(ee, 'B'), byName(ee, 'C')]);
  const lgE = await post(E, '/log', { actor: 'user', events: [
    { action: 'effort', id: 'a', name: 'Etch', from: { effort: null }, to: { effort: 16 }, via: 'table-edit' },
    { action: 'effort', id: 'a', name: 'Etch', from: { effort: 16 }, to: { effort: 40 }, via: 'item-editor' },
    { action: 'effort', id: 'a', name: 'Etch', from: { effort: 40 }, to: { effort: null }, via: 'undo' },
    { action: 'effort', id: 'a', name: 'Etch', from: 'junk', to: { effort: 'x' } },
  ] }, { Authorization: 'Bearer ' + E.token });
  ok(lgE.status === 200, '/log accepts effort events', lgE);
  const mdE = await getT(E, '/changelog?format=md');
  const wantE = ['set the effort on "Etch" to 16h (was not estimated) (table-edit)', 'set the effort on "Etch" to 40h (was 16h) (item-editor)', 'cleared the effort on "Etch" (was 40h) (undo)', 'cleared the effort on "Etch" (was not estimated)'];
  const missE = wantE.filter(l => !mdE.includes(l));
  ok(missE.length === 0, 'the markdown changelog reads effort events as sentences, junk included', missE);
  stop(E);

  // ── Google Chat digests (0.67.0), against a fake Chat on loopback ──
  {
  const http = await import('node:http');
  const posts = []; let failNext = 0;
  const fake = http.createServer((req, res) => {
    let b = ''; req.on('data', d => b += d); req.on('end', () => {
      if (failNext > 0) { failNext--; res.writeHead(500); res.end('boom with token=SECRETTOKEN in it'); return; }
      try { posts.push({ url: req.url, body: JSON.parse(b) }); } catch (e) { posts.push({ url: req.url, body: null }); }
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}');
    });
  });
  const fakePort = await new Promise(r => fake.listen(0, '127.0.0.1', () => r(fake.address().port)));
  const hook = `http://127.0.0.1:${fakePort}/v1/spaces/TEST/messages?key=SECRETKEY&token=SECRETTOKEN`;
  const fastEnv = { ADOM_GANTT_CHAT_ALLOW_LOOPBACK: '1', ADOM_GANTT_CHAT_QUIET_MS: '1200', ADOM_GANTT_CHAT_MAX_MS: '6000', ADOM_GANTT_CHAT_TICK_MS: '300' };
  const CH = await start('chat', basePlan(false), null, 8946, fastEnv);
  const wait = ms => new Promise(r => setTimeout(r, ms));
  const until = async (fn, ms = 6000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await wait(100); } return fn(); };
  let st = await getJ(CH, '/notify');
  ok(st.configured === false && st.cadence === 'live', 'GET /notify reports not configured on a fresh plan', st);
  let r = await post(CH, '/notify', { webhook: 'https://example.com/hook' });
  ok(r.status === 400, 'a URL that is not a Chat webhook is refused', r.status);
  r = await post(CH, '/notify', { webhook: hook, publicUrl: 'https://gantt-test.adom.cloud' });
  ok(r.status === 200 && r.j.configured === true, 'the webhook is set from this machine', r.j);
  ok(!JSON.stringify(r.j).includes('SECRET'), 'the status never carries the key or the token', r.j);
  const cf = path.join(CH.dir, 'plan-chat.json');
  ok(fs.existsSync(cf) && (fs.statSync(cf).mode & 0o777) === 0o600, 'the webhook is kept in a 0600 file beside the plan', (fs.statSync(cf).mode & 0o777).toString(8));
  // Two people, three changes; the server's own bookkeeping is not posted.
  await post(CH, '/log', { events: [
    { action: 'owner', id: 'etch', name: 'Etch the cards', from: { owner: '' }, to: { owner: 'Kai' }, actor: 'Ana Lopez', via: 'table-edit' },
    { action: 'dates', id: 'ship', name: 'Ship', from: { start: '2026-10-12', end: '2026-10-16' }, to: { start: '2026-10-15', end: '2026-10-19' }, shift: { start: 3, end: 3 }, actor: 'Ana Lopez', via: 'drag-move' },
    { action: 'status', id: 'ship', name: 'Ship', from: { status: 'not started' }, to: { status: 'in progress' }, actor: 'Kai Smith', via: 'table-edit' },
    { action: 'uid.assign', count: 1, actor: 'adom-gantt' },
  ] });
  ok(posts.length === 0, 'nothing is posted while the burst is still going');
  ok(await until(() => posts.length === 1), 'one digest after the plan has been quiet', posts.length);
  const d0 = posts[0] && posts[0].body;
  const t0 = (d0 && d0.text) || '';
  ok(/\*Ana Lopez\* · 2/.test(t0) && /\*Kai Smith\*/.test(t0), 'grouped by person, with counts', t0);
  ok(t0.includes('assigned "Etch the cards" to Kai') && t0.includes('moved +3d on "Ship"') && !t0.includes('(table-edit)'), 'each change in the changelog\'s own words, without the gesture', t0);
  ok(!/uid/.test(t0) && t0.includes('3 changes'), 'the server\'s own bookkeeping is left out', t0);
  ok(t0.includes('<https://gantt-test.adom.cloud/|open the plan>') && t0.includes('/changelog?since='), 'with a link to the plan and to the changes', t0);
  ok(d0 && d0.thread && /^adom-gantt-plan-\d{4}-\d{2}-\d{2}$/.test(d0.thread.threadKey) && /messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD/.test(posts[0].url), 'in the plan\'s thread for the day', [d0 && d0.thread, posts[0].url]);
  st = await getJ(CH, '/notify');
  ok(st.pending === 0 && st.posts === 1 && !!st.lastPostAt, 'the status says it posted and nothing is pending', st);
  // A failed post is retried and posted once.
  failNext = 1;
  await post(CH, '/log', { events: [{ action: 'desc', id: 'etch', name: 'Etch the cards', from: { desc: '' }, to: { desc: 'two passes' }, actor: 'Ana Lopez' }] });
  ok(await until(async () => !!(await getJ(CH, '/notify')).lastError), 'a failed post is recorded', await getJ(CH, '/notify'));
  st = await getJ(CH, '/notify');
  ok(!JSON.stringify(st).includes('SECRET'), 'and the error does not leak the key or the token', st.lastError);
  r = await post(CH, '/notify', { flush: true });
  ok(posts.length === 2 && posts[1].body.text.includes('two passes') && !r.j.lastError, 'the retry posts it, once', posts.length);
  // A restart does not post anything twice.
  stop(CH); await wait(400);
  const CH2 = await start('chat', basePlan(false), null, 8946, fastEnv);
  await wait(2000);
  ok(posts.length === 2, 'a restart resumes from the last posted change: nothing is posted twice', posts.length);
  // Off: nothing is posted, and turning it back on does not dump the gap.
  await post(CH2, '/notify', { cadence: 'off' });
  await post(CH2, '/log', { events: [{ action: 'owner', id: 'ship', name: 'Ship', from: { owner: '' }, to: { owner: 'Zed' }, actor: 'Ana Lopez' }] });
  await wait(2000);
  ok(posts.length === 2, 'with posting off, nothing is posted');
  await post(CH2, '/notify', { cadence: 'live' });
  await wait(2000);
  ok(posts.length === 2, 'turning it back on starts from now, not from the gap');
  // A test message goes at once.
  r = await post(CH2, '/notify', { test: true });
  ok(r.status === 200 && posts.length === 3 && /test message from adom-gantt/.test(posts[2].body.text), 'a test message posts at once', posts.length);
  r = await post(CH2, '/notify', { cadence: 'weekly' });
  ok(r.status === 400, 'an unknown cadence is refused');
  stop(CH2); fake.close();
  }

  // ── Sharing (0.68.0): sign-in reads the person's orgs, and the plan's setting decides
  // who gets in. Against a fake Adom identity service on loopback. Requests carry an
  // X-Forwarded-For, as everything through a public address does, so none of them is
  // "this machine".
  {
  const http = await import('node:http');
  const people = {
    drew: { id: 'u1', name: 'drew2', display_name: 'Drew Owens', orgs: ['adom', 'cosmiic'] },
    ana: { id: 'u2', name: 'ana', display_name: 'Ana Lopez', orgs: ['adom'] },
    zed: { id: 'u3', name: 'zed', display_name: 'Zed Out', orgs: ['elsewhere'] },
    bob: { id: 'u4', name: 'bob', display_name: 'Bob Broken', orgs: null },
    kim1: { id: 'u5', name: 'kim1', display_name: 'Kim One', orgs: ['adom'] },
    kim2: { id: 'u6', name: 'kim2', display_name: 'Kim Two', orgs: ['adom'] },
  };
  const intents = new Map(); let nextWho = 'drew', seq = 0;
  const carbon = http.createServer((req, res) => {
    const send = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
    const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');
    if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, nextWho); send(200, { token: t, auth_url: 'http://example.invalid/approve' }); return; }
    const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);
    if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }
    const p = people[who];
    if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }
    if (req.url === '/user/memberships' && p) { if (!p.orgs) { send(500, { error: 'down' }); return; } send(200, p.orgs.map(o => ({ is_owner: false, is_public: true, organization: { id: 'o-' + o, name: o } }))); return; }
    send(401, { error: 'no' });
  });
  const carbonPort = await new Promise(r => carbon.listen(0, '127.0.0.1', () => r(carbon.address().port)));
  const env = { ADOM_CARBON_API: `http://127.0.0.1:${carbonPort}`, ADOM_GANTT_CHAT_TICK_MS: '300' };
  const XFF = { 'X-Forwarded-For': '203.0.113.9' };
  let S = await start('share', basePlan(false), null, 8946, env, { auth: true });
  const signIn = async who => {
    nextWho = who;
    const st = await (await fetch(S.base + '/auth/start', { method: 'POST', headers: XFF })).json();
    const r = await fetch(S.base + '/auth/poll?token=' + st.token, { headers: XFF });
    const j = await r.json();
    return { cookie: (r.headers.get('set-cookie') || '').split(';')[0], user: j.user };
  };
  const as = async (who, u, opts = {}) => {
    const r = await fetch(S.base + u, Object.assign({}, opts, { headers: Object.assign({}, XFF, who ? { Cookie: who.cookie } : {}, opts.headers || {}) }));
    let j = null; const t = await r.text(); try { j = JSON.parse(t); } catch (e) {}
    return { status: r.status, j, t };
  };
  const asPost = (who, u, body) => as(who, u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  const agent = (u, body) => post(S, u, body, { Authorization: 'Bearer ' + S.token });
  const html = { headers: { Accept: 'text/html' } };
  // Owners in this block are plain fields: requests (0.80.0) have their own block below.
  await agent('/access', { requests: 'off' });
  const drew = await signIn('drew'), ana = await signIn('ana'), zed = await signIn('zed'), bob = await signIn('bob');
  ok(eq(drew.user && drew.user.orgs, ['adom', 'cosmiic']), 'sign-in reads the person\'s Adom orgs', drew.user);
  ok(eq(bob.user && bob.user.orgs, []), 'an org list that cannot be read is "no orgs", and sign-in still works', bob.user);
  let r = await as(zed, '/data');
  ok(r.status === 200, 'a new plan is open to anyone signed in to Adom, as before 0.68.0', r.status);
  r = await as(null, '/data');
  ok(r.status === 401, 'and to nobody who is not signed in', r.status);
  r = await as(zed, '/access');
  ok(r.status === 200 && r.j.mode === 'any' && r.j.owner === null && r.j.canChange === false, 'GET /access: the setting, no owner yet, and zed cannot change it', r.j);
  r = await asPost(zed, '/access', { mode: 'me' });
  ok(r.status === 403, 'someone who is not the owner cannot change the sharing', r.status);
  r = await asPost(drew, '/access', { owner: 'drew2' });
  ok(r.status === 403, 'nobody names themselves the owner from a page', r.status);
  r = await agent('/access', { owner: 'drew2' });
  ok(r.status === 200 && r.j.owner && r.j.owner.username === 'drew2', 'the owner is named with the agent token', r.j);
  r = await as(drew, '/access');
  ok(r.j.youAreOwner === true && r.j.canChange === true && eq(r.j.you.orgs, ['adom', 'cosmiic']), 'the owner sees they can change it, and their orgs', r.j);
  r = await asPost(drew, '/access', { mode: 'org', org: 'elsewhere' });
  ok(r.status === 400, 'the owner cannot share with an org they are not in (it would lock themselves out of the choice)', r);
  r = await asPost(drew, '/access', { mode: 'org', org: 'adom' });
  ok(r.status === 200 && r.j.mode === 'org' && r.j.org === 'adom', 'the owner shares the plan with the adom org', r.j);
  const af = path.join(S.dir, 'plan-access.json');
  ok(fs.existsSync(af) && (fs.statSync(af).mode & 0o777) === 0o600, 'the setting is kept in a 0600 file beside the plan');
  ok(JSON.parse(fs.readFileSync(af, 'utf8')).owner.displayName === 'Drew Owens', 'the owner\'s name is filled in the first time they are seen');
  r = await as(ana, '/data');
  ok(r.status === 200, 'a member of adom gets in', r.status);
  r = await as(zed, '/data');
  ok(r.status === 403 && r.j && r.j.notShared === true, 'someone outside adom is refused, with a 403 that says why', r);
  r = await as(zed, '/', html);
  ok(r.status === 403 && r.t.includes('isn\'t shared with you') && r.t.includes('adom') && !r.t.includes('Etch'), 'their browser gets a page that says so, and none of the plan', r.status);
  for (const u of ['/state', '/changelog', '/export/json', '/live?cid=z', '/notify', '/access']) {
    const x = await as(zed, u);
    if (x.status !== 403) ok(false, 'refused on ' + u, x.status);
  }
  ok(true, 'and refused on every route: /state /changelog /export /live /notify /access');
  r = await asPost(zed, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] });
  ok(r.status === 403, 'a save from outside the org is refused', r.status);
  r = await as(bob, '/data');
  ok(r.status === 403, 'someone whose orgs could not be read is outside every org', r.status);
  r = await as(drew, '/notify');
  ok(r.j && r.j.canSetWebhook === true, 'the owner may set the Chat webhook from the page', r.j);
  r = await as(ana, '/notify');
  ok(r.j && r.j.canSetWebhook === false, 'a member may not', r.j);
  r = await asPost(ana, '/notify', { webhook: 'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=K&token=T' });
  ok(r.status === 403, 'and is refused if they try', r.status);
  r = await asPost(drew, '/notify', { webhook: 'https://example.com/hook' });
  ok(r.status === 400, 'the owner\'s webhook is still checked for being a Chat webhook', r.status);
  r = await asPost(ana, '/notify', { cadence: 'hourly' });
  ok(r.status === 403, 'a member cannot change how often it posts: the owner manages Chat (0.73.0)', r.status);
  r = await asPost(drew, '/notify', { cadence: 'hourly' });
  ok(r.status === 200, 'the owner can', r.status);
  // Project dates (0.75.0) are the owner's, like the sharing: a member's save keeps them.
  const st0 = (await as(drew, '/state')).j || {};
  const plain = { v: 4, items: st0.items || [], phases: st0.phases || [], order: st0.order || [], deleted: st0.deleted || [], added: st0.added || [] };
  const sDates = async () => ((await as(drew, '/state')).j || {}).dates;
  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: 'soon' } }));
  let sd = await sDates();
  ok(r.status === 200 && sd && sd.start === '2026-09-14' && sd.end === '', 'the owner sets the project dates; a malformed one is kept as "not set"', sd);
  r = await asPost(ana, '/save', Object.assign({}, plain, { dates: { start: '2020-01-01', end: '2020-02-01' } }));
  sd = await sDates();
  ok(r.status === 200 && sd && sd.start === '2026-09-14' && sd.end === '', 'a member\'s save keeps the owner\'s dates, whatever it carries', sd);
  r = await asPost(ana, '/save', plain);
  sd = await sDates();
  ok(r.status === 200 && sd && sd.start === '2026-09-14', 'and so does a save with no dates at all (a tab older than 0.75.0)', sd);
  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' } }));
  const ex = (await as(drew, '/export/json')).j;
  ok(ex && ex.project && ex.project.start === '2026-09-14' && ex.project.end === '2026-12-18', 'the merged plan (exports, snapshots, agents) carries them as project.start and project.end', ex && ex.project);
  await asPost(drew, '/log', { events: [{ action: 'plan.dates', from: { start: '', end: '' }, to: { start: '2026-09-14', end: '2026-12-18' } }] });
  const pdMd = (await as(drew, '/changelog?format=md')).t;
  ok(/set the project start to 14-Sep-2026 \(was not set\) and the project end to 18-Dec-2026 \(was not set\)/.test(pdMd), 'the changelog says what was set, in one line', pdMd.slice(-300));
  // The working calendar (0.76.0, issue #3) is the owner's in the same way.
  const sCal = async () => ((await as(drew, '/state')).j || {}).calendar;
  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' }, calendar: { workHolidays: ['Veterans Day', 'Columbus Day', 'Festivus'], extraDaysOff: ['2026-12-24', 'later', '2026-11-27', '2026-12-24'] } }));
  let sc = await sCal();
  ok(r.status === 200 && sc && sc.workHolidays.join() === 'Columbus Day,Veterans Day' && sc.extraDaysOff.join() === '2026-11-27,2026-12-24', 'the owner sets the working calendar; unknown names, bad days and repeats are dropped', sc);
  r = await asPost(ana, '/save', Object.assign({}, plain, { calendar: { workHolidays: ['Christmas Day'], extraDaysOff: [] } }));
  sc = await sCal();
  ok(r.status === 200 && sc && sc.workHolidays.join() === 'Columbus Day,Veterans Day' && sc.extraDaysOff.length === 2, 'a member\'s save keeps the owner\'s calendar, whatever it carries', sc);
  r = await asPost(ana, '/save', plain);
  sc = await sCal();
  ok(r.status === 200 && sc && sc.workHolidays.length === 2, 'and so does a save with no calendar at all (a tab older than 0.76.0)', sc);
  const exc = (await as(drew, '/export/json')).j;
  ok(exc && exc.project && exc.project.calendar && exc.project.calendar.workHolidays.join() === 'Columbus Day,Veterans Day' && exc.project.calendar.extraDaysOff.join() === '2026-11-27,2026-12-24', 'the merged plan carries it as project.calendar', exc && exc.project);
  await asPost(drew, '/log', { events: [{ action: 'plan.calendar', from: { workHolidays: [], extraDaysOff: [] }, to: { workHolidays: ['Columbus Day'], extraDaysOff: ['2026-11-27'] } }] });
  await asPost(drew, '/log', { events: [{ action: 'plan.calendar', from: { workHolidays: ['Columbus Day'] }, to: 'junk' }] });
  const calMd = (await as(drew, '/changelog?format=md')).t;
  ok(/changed the working calendar: works Columbus Day; added 27-Nov-2026 as a day off/.test(calMd) && /changed the working calendar: takes Columbus Day off/.test(calMd), 'the changelog says what changed, and a malformed event still reads', calMd.slice(-400));
  r = await asPost(drew, '/save', Object.assign({}, plain, { dates: { start: '2026-09-14', end: '2026-12-18' }, calendar: { workHolidays: [], extraDaysOff: [] } }));
  const exd = (await as(drew, '/export/json')).j;
  ok(r.status === 200 && exd && exd.project && !('calendar' in exd.project), 'an empty calendar is the default, and the merged plan says nothing', exd && exd.project);
  // ── Field-level ops (0.77.0): what changed, field by field, applied in arrival order ──
  {
    const ex0 = (await as(drew, '/export/json')).j;
    const etch = ex0.items.find(x => x.id === 'etch'), ship = ex0.items.find(x => x.id === 'ship'), launch = ex0.items.find(x => x.name === 'Launch');
    const opsP = (who, ops, extra = {}) => asPost(who, '/ops', Object.assign({ client: 't', batch: 'b', ops }, extra));
    const exp = async () => (await as(drew, '/export/json')).j;
    const rowOf = async uid => ((await exp()).items || []).find(x => x.uid === uid);
    const rev0 = ((await as(drew, '/health')).j || {}).stateRev;
    r = await opsP(ana, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana' } }]);
    ok(r.status === 200 && r.j.stateRev === rev0 + 1 && r.j.ops.length === 1 && r.j.ops[0].set.owner === 'Ana', 'a member\'s field op lands as one revision and comes back as stored', r.j);
    r = await opsP(drew, [{ op: 'row', uid: etch.uid, set: { status: 'blocked' } }]);
    let e = await rowOf(etch.uid);
    ok(e.owner === 'Ana' && e.status === 'blocked', 'two people\'s fields on one row both stand: the last write wins per field, not per row', [e.owner, e.status]);
    await opsP(ana, [{ op: 'row', uid: ship.uid, add: { deps: ['x1'], tags: ['Alpha'] } }]);
    await opsP(drew, [{ op: 'row', uid: ship.uid, add: { deps: ['x2'], tags: ['beta', 'ALPHA'] }, del: { deps: ['etch'] } }]);
    e = await rowOf(ship.uid);
    ok(eq(e.deps, ['x1', 'x2']) && eq(e.tags, ['Alpha', 'beta']), 'lists merge item by item: two people\'s additions both stand, a removal takes one item, tags match without regard to case', [e.deps, e.tags]);
    r = await opsP(drew, [{ op: 'row', uid: launch.uid, set: { date: '2026-10-22', s: '2026-01-01', effort: 5 } }]);
    ok(r.status === 200 && eq(Object.keys(r.j.ops[0].set), ['date']) && (await rowOf(launch.uid)).date === '2026-10-22', 'a milestone takes its date and ignores the task-only fields', r.j.ops);
    r = await opsP(drew, [{ op: 'row', uid: etch.uid, set: { e: 'tomorrow', name: '', owner: 'Okay' } }]);
    ok(r.status === 200 && eq(Object.keys(r.j.ops[0].set), ['owner']) && r.j.refused.length === 2, 'a bad field is skipped and named; the rest of the op lands', r.j);
    const revA = ((await as(drew, '/health')).j || {}).stateRev;
    r = await opsP(drew, [{ op: 'row', uid: '99999', set: { owner: 'x' } }, { op: 'nonsense' }, 5]);
    ok(r.status === 200 && !r.j.ops.length && r.j.stateRev === revA && r.j.refused.length === 3, 'ops on a row the plan does not have, of an unknown kind or not an op change nothing and make no revision', r.j);
    r = await as(drew, '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ops":5}' });
    ok(r.status === 400, 'a body whose ops are not a list is refused', r.status);
    r = await opsP(drew, new Array(5001).fill({ op: 'move', uid: 'x', after: null }));
    ok(r.status === 413, 'a batch of more than 5000 ops is refused', r.status);
    r = await as(null, '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ops":[]}' });
    ok(r.status === 401, 'nobody who is not signed in can send ops', r.status);
    r = await opsP(ana, [{ op: 'add', row: { type: 'task', uid: '00777', id: 'etch', name: 'Etch again', start: '2026-10-05', end: '2026-10-06', phase: 0 } }, { op: 'move', uid: '00777', after: null }]);
    const ord = r.j.ops.find(o => o.op === 'order');
    ok(r.j.ops[0].row && r.j.ops[0].row.id === 'etch-2' && ord && ord.order[0] === '00777', 'a new row lands with its id made unique (etch is taken), and a move puts it first', r.j.ops);
    let ex = await exp();
    ok(ex.items[0].uid === '00777' && ex.items[0].id === 'etch-2' && ex.items.length === ex0.items.length + 1, 'the merged plan has it, first, with the server\'s id', ex.items.map(x => x.uid));
    await opsP(drew, [{ op: 'del', uid: '00777' }, { op: 'del', uid: etch.uid }]);
    ex = await exp();
    ok(!ex.items.some(x => x.uid === '00777' || x.uid === etch.uid), 'a created row and a data-file row can both be deleted', ex.items.map(x => x.uid));
    r = await opsP(ana, [{ op: 'row', uid: etch.uid, set: { owner: 'Back' } }]);
    e = await rowOf(etch.uid);
    ok(r.j.ops[0].op === 'add' && e && e.owner === 'Back', 'an edit from a tab that had not heard of a deletion brings the row back with it, and says so', r.j.ops);
    r = await opsP(drew, [{ op: 'add', row: { uid: '00777' } }]);
    e = await rowOf('00777');
    ok(e && e.name === 'Etch again' && e.id === 'etch-2', 'putting a deleted created row back (an undo) restores it whole', e);
    const dates0 = ((await as(drew, '/state')).j || {}).dates;
    r = await opsP(ana, [{ op: 'dates', dates: { start: '2020-01-01', end: '' } }, { op: 'cal', calendar: { workHolidays: ['Labor Day'] } }]);
    ok(r.j.refused.filter(x => /only the owner/.test(x)).length === 2 && eq(((await as(drew, '/state')).j || {}).dates, dates0), 'the project dates and the calendar stay the owner\'s over ops too', r.j);
    r = await opsP(drew, [{ op: 'cal', calendar: { workHolidays: ['Labor Day'], extraDaysOff: [] } }]);
    ok(eq((((await as(drew, '/state')).j || {}).calendar || {}).workHolidays, ['Labor Day']), 'and the owner changes them', r.j);
    r = await agent('/ops', { ops: [{ op: 'row', uid: ship.uid, set: { note: 'by agent' } }] });
    ok(r.status === 200 && (await rowOf(ship.uid)).note === 'by agent', 'the agent token can send ops', r.j || r.status);
    // Every open tab hears every batch, the sender's own included, with what it needs to
    // apply it in order: the revision, who, which tab and batch, and the ops as stored.
    const live2 = await fetch(S.base + '/live?cid=ana2', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });
    const rd = live2.body.getReader(); let buf = '';
    const hear = async pred => { const t0 = Date.now(); while (Date.now() - t0 < 5000) { const { value, done } = await rd.read(); if (done) break; buf += Buffer.from(value).toString(); if (pred(buf)) return true; } return false; };
    await hear(b => b.includes('"type":"hello"'));
    r = await opsP(drew, [{ op: 'row', uid: ship.uid, set: { note: 'live' } }], { client: 'drewtab', batch: 'drewtab.9' });
    const heard = await hear(b => b.includes('drewtab.9'));
    const line = buf.split('\n').find(l => l.startsWith('data:') && l.includes('drewtab.9'));
    const msg = line ? JSON.parse(line.slice(5)) : {};
    ok(heard && msg.type === 'rev' && msg.stateRev === r.j.stateRev && msg.by === 'drewtab' && msg.batch === 'drewtab.9' && msg.actor === 'Drew Owens' && msg.ops[0].set.note === 'live', 'every tab hears the batch: its revision, who, which tab, and the ops as stored', msg);
    try { await rd.cancel(); } catch (e2) {}
    // Put the plan back the way the tests below expect it.
    await opsP(drew, [{ op: 'del', uid: '00777' }, { op: 'row', uid: etch.uid, set: { owner: '', status: '' } }, { op: 'row', uid: ship.uid, set: { deps: ['etch'], tags: [], note: '' } }, { op: 'cal', calendar: { workHolidays: [], extraDaysOff: [] } }]);
  }
  // The live stream of someone the owner stops sharing with is told and closed.
  const live = await fetch(S.base + '/live?cid=ana1', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });
  const reader = live.body.getReader(); let got = '';
  const reading = (async () => { const t0 = Date.now(); while (Date.now() - t0 < 5000) { const { value, done } = await reader.read(); if (done) return 'closed'; got += Buffer.from(value).toString(); } return 'timeout'; })();
  await new Promise(res => setTimeout(res, 300));
  r = await asPost(drew, '/access', { mode: 'me' });
  ok(r.status === 200 && r.j.mode === 'me', 'the owner makes it owner-only', r.j);
  const how = await reading;
  ok(how === 'closed' && got.includes('"type":"denied"'), 'an open live stream of a member is told it is denied, and closed', [how, got.slice(-200)]);
  r = await as(ana, '/data');
  ok(r.status === 403, 'the member is now refused', r.status);
  r = await as(drew, '/data');
  ok(r.status === 200, 'the owner is not', r.status);
  r = await agent('/data', undefined);
  ok((await fetch(S.base + '/data', { headers: { Authorization: 'Bearer ' + S.token } })).status === 200, 'the agent token is unaffected by the sharing');
  const md = await (await fetch(S.base + '/changelog?format=md', { headers: { Authorization: 'Bearer ' + S.token } })).text();
  ok(md.includes('shared the plan with the adom organization (was anyone signed in to Adom)') && md.includes('shared the plan with its owner only (was the adom organization)'), 'each change of sharing is in the changelog, in words', md.split('\n').filter(l => /shared/.test(l)));
  // A session from before 0.68.0 has no orgs: under "org" it is asked to sign in again.
  r = await asPost(drew, '/access', { mode: 'org', org: 'adom' });
  stop(S); await new Promise(res => setTimeout(res, 300));
  const sf = path.join(S.dir, 'plan-sessions.json');
  const sess = JSON.parse(fs.readFileSync(sf, 'utf8'));
  const anaSid = decodeURIComponent(ana.cookie.split('=')[1]);
  delete sess[anaSid].user.orgs;
  fs.writeFileSync(sf, JSON.stringify(sess));
  S = await start('share', basePlan(false), null, 8946, env, { auth: true });
  r = await as(ana, '/data');
  ok(r.status === 401 && /sign in again/.test(r.j && r.j.error), 'an old session with no orgs is asked to sign in again', r);
  r = await as(ana, '/data');
  ok(r.status === 401, 'and that session is gone', r.status);
  const ana2 = await signIn('ana');
  r = await as(ana2, '/data');
  ok(r.status === 200, 'signing in again reads the orgs, and lets them in', r.status);
  // Backups: before every import and compact, and one a day, keeping the last 30.
  const bdir = path.join(S.dir, 'plan-backups');
  ok(await (async () => { for (let i = 0; i < 30; i++) { if (fs.existsSync(bdir) && fs.readdirSync(bdir).some(d => d.endsWith('-daily'))) return true; await new Promise(res => setTimeout(res, 100)); } return false; })(), 'a daily backup is taken once the server is up');
  r = await post(S, '/import/json', basePlan(true), { Authorization: 'Bearer ' + S.token });
  const imp = fs.readdirSync(bdir).filter(d => d.endsWith('-import'));
  ok(r.status === 200 && imp.length === 1 && fs.existsSync(path.join(bdir, imp[0], 'plan.json')), 'an import first backs up the plan it replaces', [r.status, fs.readdirSync(bdir)]);
  ok(imp.length === 1 && !JSON.parse(fs.readFileSync(path.join(bdir, imp[0], 'plan.json'), 'utf8')).items[0].tags, 'and the backup is the plan as it was before the import');
  for (let i = 0; i < 35; i++) fs.mkdirSync(path.join(bdir, '2000-01-01T00-00-' + String(i).padStart(2, '0') + '-000Z-old'), { recursive: true });
  r = await post(S, '/compact', {}, { Authorization: 'Bearer ' + S.token });
  const after = fs.readdirSync(bdir);
  ok(after.some(d => d.endsWith('-compact')) && after.length === 30 && !after.includes('2000-01-01T00-00-00-000Z-old'), 'a compact backs up first, and only the last 30 backups are kept', [r.status, after.length]);
  stop(S);
  // --no-loopback: a request from this machine is nobody special.
  const L = await start('noloop', basePlan(false), null, 8947, env, { auth: true, args: ['--no-loopback'] });
  r = await fetch(L.base + '/data');
  ok(r.status === 401, 'with --no-loopback, a loopback read without a session is refused', r.status);
  r = await fetch(L.base + '/data', { headers: { Authorization: 'Bearer ' + L.token } });
  ok(r.status === 200, 'and the agent token still works', r.status);
  stop(L);
  const L2 = await start('loop', basePlan(false), null, 8947, env, { auth: true });
  r = await fetch(L2.base + '/data');
  ok(r.status === 200, 'without it, a direct loopback read still works for the AI handoff loop', r.status);
  stop(L2);
  // ── Requests (0.80.0): an owner change onto somebody else is a request they answer ──
  {
  console.log('Requests');
  const Q = await start('requests', basePlan(false), null, 8947, env, { auth: true });
  S = Q;
  const tok = { Authorization: 'Bearer ' + Q.token };
  await post(Q, '/access', { owner: 'drew2' }, tok);
  const dr = await signIn('drew'), an = await signIn('ana'), ze = await signIn('zed');
  const opsQ = (who, ops) => asPost(who, '/ops', { client: 't', batch: 'b', ops });
  const agentOps = ops => post(Q, '/ops', { ops }, tok);
  const exp = async () => (await as(dr, '/export/json')).j;
  const reqs = async q => (await as(dr, '/requests' + (q || ''))).j;
  const md = async () => (await fetch(Q.base + '/changelog?format=md', { headers: tok })).text();
  let ex = await exp();
  const etch = ex.items.find(x => x.id === 'etch'), ship = ex.items.find(x => x.id === 'ship'), launch = ex.items.find(x => x.name === 'Launch');
  r = await as(dr, '/access');
  ok(r.j.requests === 'on', 'requests are on by default on a plan with sign-in', r.j);
  r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana', status: 'in-progress' } }]);
  ex = await exp();
  let e = ex.items.find(x => x.uid === etch.uid);
  ok(r.status === 200 && (e.owner || '') === '' && e.status === 'in-progress', 'an owner change onto somebody else is not applied; the rest of the op lands', [e.owner, e.status]);
  ok(r.j.requested.length === 1 && r.j.requested[0].to === 'Ana' && r.j.ops.some(o => o.op === 'req' && o.req.state === 'pending' && o.req.uid === etch.uid), 'it becomes a pending request, in the answer and in the announced ops', r.j);
  const id1 = r.j.requested[0].id;
  let st = (await as(dr, '/state')).j;
  ok(Array.isArray(st.requests) && st.requests[0].id === id1 && st.requests[0].from.name === 'Drew Owens' && st.requests[0].was === '', 'it is kept in the sidecar, with who asked and the owner it had', st.requests);
  r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Ana' } }]);
  ok(r.j.requested.length === 1 && r.j.requested[0].id === id1 && (await reqs('?state=pending')).count === 1, 'asking the same person again is the same request', r.j);
  r = await as(an, '/requests');
  ok(r.j.mine && eq(r.j.mine.for, [id1]) && r.j.requests[0].row.name === 'Etch the cards', 'Ana sees it as hers to answer ("Ana" is the first word of Ana Lopez), with the row\'s name', r.j.mine);
  r = await as(dr, '/requests');
  ok(eq(r.j.mine.asked, [id1]) && eq(r.j.mine.for, []), 'Drew sees it as one he asked', r.j.mine);
  r = await opsQ(ze, [{ op: 'req.answer', id: id1, accept: true }]);
  ok(r.j.refused.some(x => /only Ana or the plan's owner/.test(x)) && (await reqs('?id=' + id1)).requests[0].state === 'pending', 'somebody else cannot answer it', r.j.refused);
  r = await agentOps([{ op: 'req.answer', id: id1, accept: true }]);
  ok(r.j.refused.some(x => /agent key answers only for a named person/.test(x)), 'the agent key cannot answer without naming the person it acts for', r.j.refused);
  r = await agentOps([{ op: 'req.answer', id: id1, accept: true, for: 'ana' }]);
  ok(r.j.refused.some(x => /Ana Lopez has not let their AI answer/.test(x)), 'nor for a person who has not let their AI answer for them', r.j.refused);
  r = await opsQ(an, [{ op: 'req.answer', id: id1, accept: true, note: 'yes, after Friday' }]);
  ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);
  ok(e.owner === 'Ana' && r.j.ops.some(o => o.op === 'row' && o.uid === etch.uid && o.set.owner === 'Ana'), 'Ana accepts: the row is hers, announced as a row op every tab applies', r.j.ops);
  let rq = (await reqs('?id=' + id1)).requests[0];
  ok(rq.state === 'accepted' && rq.by.name === 'Ana Lopez' && rq.reply === 'yes, after Friday', 'the requester sees who accepted it, and the note', rq);
  st = (await as(dr, '/state')).j;
  ok(st.people && st.people.ana && st.people.ana.name === 'Ana Lopez' && r.j.ops.some(o => o.op === 'people'), 'the name she accepted under is linked to her account', st.people);
  r = await opsQ(an, [{ op: 'req.reopen', id: id1 }]);
  ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);
  ok((e.owner || '') === '' && (await reqs('?id=' + id1)).requests[0].state === 'pending', 'Ana\'s undo of her accept takes the row back off her plate and the request is pending again', [e.owner]);
  r = await opsQ(an, [{ op: 'req.answer', id: id1, accept: false, note: 'no room this month' }]);
  ex = await exp(); e = ex.items.find(x => x.uid === etch.uid);
  rq = (await reqs('?id=' + id1)).requests[0];
  ok(rq.state === 'declined' && rq.reply === 'no room this month' && (e.owner || '') === '', 'a decline leaves the row as it was, and keeps the note', [rq, e.owner]);
  // Direct: to yourself, cleared, put back.
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);
  ex = await exp();
  ok(ex.items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 0, 'taking a task yourself is direct', r.j);
  // The agent.
  r = await agentOps([{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);
  ex = await exp();
  ok(ex.items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 1, 'the agent key cannot reassign either: it asks', r.j);
  const id2 = r.j.requested[0].id;
  rq = (await reqs('?id=' + id2)).requests[0];
  ok(rq.from.name === 'adom-gantt agent' && rq.was === 'Drew', 'the request says the agent asked, and whose it was', rq);
  r = await opsQ(an, [{ op: 'req.answer', id: id2, accept: false, note: 'ask Drew' }]);
  r = await agentOps([{ op: 'req', uid: ship.uid, to: 'Ana' }]);
  ok(r.j.requested.length === 0 && r.j.refused.some(x => /Ana declined it on .* \("ask Drew"\); ask a person/.test(x)), 'the agent may not ask again after a decline', r.j.refused);
  r = await opsQ(dr, [{ op: 'req', uid: ship.uid, to: 'Ana', note: 'please, it is urgent' }]);
  const id3 = r.j.requested[0] && r.j.requested[0].id;
  ok(id3 && (await reqs('?id=' + id3)).requests[0].note === 'please, it is urgent', 'a person may ask again, with a note', r.j);
  r = await opsQ(an, [{ op: 'req.withdraw', id: id3 }]);
  ok(r.j.refused.some(x => /only Drew Owens or the plan's owner can withdraw/.test(x)), 'the person asked cannot withdraw it', r.j.refused);
  r = await opsQ(dr, [{ op: 'req.withdraw', id: id3 }]);
  ok((await reqs('?id=' + id3)).requests[0].state === 'withdrawn', 'the requester withdraws it', r.j);
  // A new row for somebody else is made unassigned and asked of them.
  r = await agentOps([{ op: 'add', row: { uid: '00080', type: 'task', id: 'new-for-ana', name: 'New for Ana', start: '2026-10-05', end: '2026-10-07', phase: 0, owner: 'Ana' } }]);
  ex = await exp(); e = ex.items.find(x => x.uid === '00080');
  ok(e && (e.owner || '') === '' && r.j.requested.length === 1 && (await reqs('?uid=00080')).requests[0].created === true, 'a row made for somebody else is made unassigned and asked of them', [e, r.j.requested]);
  r = await agentOps([{ op: 'del', uid: '00080' }]);
  ok((await reqs('?uid=00080')).requests[0].state === 'closed' && (await reqs('?uid=00080')).requests[0].why === 'row deleted', 'deleting the row closes its request', (await reqs('?uid=00080')).requests);
  // Old-style whole-state /save, from a script with the agent key and from a person.
  st = (await as(dr, '/state')).j;
  const body = JSON.parse(JSON.stringify(st)); delete body.integrity; delete body.stateRev;
  const msOv = body.items.find(o => o.uid === launch.uid);
  if (msOv) msOv.owner = 'Zed'; else body.items.push({ uid: launch.uid, owner: 'Zed', name: 'Launch', date: launch.date, phase: launch.phase, deps: [] });
  body.requests = []; body.people = {};
  r = await post(Q, '/save', body, tok);
  ex = await exp(); st = (await as(dr, '/state')).j;
  ok(r.status === 200 && (ex.items.find(x => x.uid === launch.uid).owner || '') === '' && r.j.requested.length === 1 && r.j.requested[0].to === 'Zed', 'a whole-state /save cannot reassign: the old owner is stored and Zed is asked', [r.j, ex.items.find(x => x.uid === launch.uid)]);
  ok(st.requests.length >= 4 && st.people && st.people.ana, 'and a /save cannot write the requests or the names: they are kept', [st.requests.length, st.people]);
  // An older tab's own changelog line for an owner change that became a request is dropped.
  r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Zed' } }]);
  r = await asPost(dr, '/log', { events: [{ action: 'owner', id: 'etch', name: 'Etch the cards', from: { owner: '' }, to: { owner: 'Zed' }, via: 'table-edit' }] });
  ok(r.j.added === 0 && r.j.dropped === 1, 'an older tab\'s "reassigned" line for a change that became a request is not stored', r.j);
  r = await asPost(dr, '/log', { events: [{ action: 'status', id: 'etch', name: 'Etch the cards', from: { status: '' }, to: { status: 'blocked' } }] });
  ok(r.j.added === 1, 'its other lines are', r.j);
  // Undo: putting the owner back by the person who changed it is direct.
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: '' } }]);
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);
  r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);
  ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Ana', 'Ana takes Drew\'s task herself: direct', r.j);
  r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { owner: 'Drew' } }]);
  ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 0, 'and her undo puts it back with Drew, without asking him', r.j);
  r = await opsQ(ze, [{ op: 'row', uid: ship.uid, set: { owner: 'Ana' } }]);
  ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Drew' && r.j.requested.length === 1, 'but somebody else moving it to Ana is a request', r.j);
  // A request replaced by a newer one, and one closed by a direct change.
  const pend = (await reqs('?uid=' + ship.uid + '&state=pending')).requests[0];
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Zed' } }]);
  ok((await reqs('?id=' + pend.id)).requests[0].state === 'closed' && (await reqs('?uid=' + ship.uid + '&state=pending')).requests[0].to === 'Zed', 'one pending request per row: a newer one replaces it', (await reqs('?uid=' + ship.uid)).requests.map(x => [x.to, x.state, x.why]));
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: '' } }]);
  ok((await reqs('?uid=' + ship.uid + '&state=pending')).count === 0, 'a direct owner change closes the row\'s pending request', r.j);
  // The changelog and the Chat digest say it in words, and the chain stays whole.
  const m = await md();
  ok(/Drew Owens asked Ana to take "Etch the cards"/.test(m) && /Ana Lopez accepted "Etch the cards", asked by Drew Owens: "yes, after Friday"/.test(m)
    && /Ana Lopez declined "Etch the cards", asked by Drew Owens: "no room this month"/.test(m) && /adom-gantt agent asked Ana to take "Ship" \(owned by Drew\)/.test(m)
    && /Drew Owens withdrew the request for Ana to take "Ship"/.test(m) && /took back their accept/.test(m) && /asked Ana to take the new task "New for Ana"/.test(m), 'the changelog has every request event, in words', m.split('\n').filter(l => /ask|accept|declin|withdr/.test(l)).slice(0, 12));
  ok(!/reassigned "Etch the cards" from  to Zed|assigned "Etch the cards" to Zed/.test(m), 'and no line claims the reassignment that did not happen', '');
  ok((await getJ(Q, '/health')).integrity.ok === true, 'the changelog chain is whole after the server wrote the request lines');
  // A name with no Adom account behind it is assigned directly (Drew), and /people says so.
  r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { owner: 'Bob' } }]);
  ok((await exp()).items.find(x => x.uid === launch.uid).owner === 'Bob' && r.j.requested.length === 0, 'a name nobody with an Adom account answers to is assigned directly', r.j);
  let pv = (await as(dr, '/people')).j;
  ok(pv.names.find(x => x.name === 'Bob').how === 'none' && pv.unlinked >= 1 && pv.names.find(x => x.name.toLowerCase() === 'ana').how === 'linked', '/people lists Bob as not linked, and Ana as linked by her accept', pv.names);
  r = await opsQ(an, [{ op: 'people.link', name: 'Bob', account: 'u:u3' }]);
  ok(r.j.refused.some(x => /only the plan's owner links/.test(x)), 'only the plan\'s owner links a name to an account', r.j.refused);
  r = await opsQ(dr, [{ op: 'people.link', name: 'Bob', account: 'u:u3' }]);
  pv = (await as(dr, '/people')).j;
  ok(pv.names.find(x => x.name === 'Bob').how === 'linked' && pv.names.find(x => x.name === 'Bob').accounts[0].name === 'Zed Out' && r.j.ops.some(o => o.op === 'people'), 'the owner links Bob to Zed Out\'s account', pv.names.find(x => x.name === 'Bob'));
  r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { owner: '' } }, { op: 'row', uid: launch.uid, set: { owner: 'Bob' } }]);
  ok(r.j.requested.length === 1 && r.j.requested[0].to === 'Bob', 'and a change onto Bob is now a request Zed answers', r.j);
  r = await opsQ(ze, [{ op: 'req.answer', id: r.j.requested[0].id, accept: true }]);
  ok((await exp()).items.find(x => x.uid === launch.uid).owner === 'Bob', 'Zed accepts it as Bob', r.j);
  // Two people who share a first name: still asked, and /people warns.
  await signIn('kim1'); await signIn('kim2'); S = Q;
  pv = (await as(dr, '/people')).j;
  r = await opsQ(dr, [{ op: 'row', uid: etch.uid, set: { owner: 'Kim' } }]);
  pv = (await as(dr, '/people?name=Kim')).j;
  ok(r.j.requested.length === 1 && pv.lookup.how === 'shared' && pv.lookup.accounts.length === 2, 'a first name two people share is still asked, and flagged with both accounts', [r.j.requested, pv.lookup]);
  // An AI answers for a person only with their permission, and says whose.
  r = await agentOps([{ op: 'ai.answers', on: true }]);
  ok(r.j.refused.some(x => /only a signed-in person/.test(x)), 'the agent key cannot give itself permission', r.j.refused);
  r = await opsQ(an, [{ op: 'ai.answers', on: true }]);
  ok(r.j.ops.some(o => o.op === 'delegates' && o.delegates['u:u2'] && o.delegates['u:u2'].on), 'Ana lets her AI answer for her; the server keeps it', r.j.ops);
  r = await opsQ(dr, [{ op: 'req', uid: ship.uid, to: 'Ana', note: 'for your AI' }]);
  const idAi = r.j.requested[0].id;
  r = await agentOps([{ op: 'req.answer', id: idAi, accept: true, for: 'zed' }]);
  ok(r.j.refused.some(x => /has not let their AI|only Ana/.test(x)), 'the agent cannot answer Ana\'s request in somebody else\'s name', r.j.refused);
  r = await agentOps([{ op: 'req.answer', id: idAi, accept: true, for: 'ana', note: 'she said yes in Chat' }]);
  rq = (await reqs('?id=' + idAi)).requests[0];
  ok(rq.state === 'accepted' && rq.by.for && rq.by.for.name === 'Ana Lopez' && (await exp()).items.find(x => x.uid === ship.uid).owner === 'Ana', 'with her permission the agent accepts for her, and the request says so', rq);
  ok(/Ana Lopez's AI accepted "Ship" for them, asked by Drew Owens: "she said yes in Chat"/.test(await md()), 'the changelog says "Ana Lopez\'s AI accepted"', '');
  await opsQ(an, [{ op: 'ai.answers', on: false }]);
  // Moving the dates of somebody else's task tells them: one notice per batch per person.
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { s: '2026-10-13', e: '2026-10-19' } }]);
  let nt = r.j.ops.find(o => o.op === 'notice');
  ok(nt && nt.notice.to === 'Ana' && eq(nt.notice.ids, ['u:u2']) && nt.notice.count === 1 && nt.notice.rows[0].was.s === '2026-10-12', 'Drew moves Ana\'s task: a notice to Ana, with the dates it had', nt);
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { e: '2026-10-21' } }]);
  const nt2 = r.j.ops.find(o => o.op === 'notice');
  ok(nt2 && nt2.notice.id === nt.notice.id && nt2.notice.rows[0].was.s === '2026-10-12' && nt2.notice.rows[0].now.e === '2026-10-21', 'a second move a moment later updates the same notice, not a new one', nt2);
  r = await opsQ(an, [{ op: 'row', uid: ship.uid, set: { e: '2026-10-22' } }]);
  ok(!r.j.ops.some(o => o.op === 'notice'), 'moving your own task tells nobody', r.j.ops);
  r = await opsQ(dr, [{ op: 'row', uid: launch.uid, set: { date: '2026-10-23' } }, { op: 'row', uid: ship.uid, set: { s: '2026-10-14' } }]);
  ok(r.j.ops.filter(o => o.op === 'notice').length === 2, 'one batch moving two people\'s rows: one notice each', r.j.ops.filter(o => o.op === 'notice').map(o => o.notice.to));
  r = await as(an, '/requests');
  ok(r.j.mine.notices.length >= 1 && r.j.notices.some(n => n.to === 'Ana'), 'Ana reads her notices from /requests', r.j.mine);
  ok(/Drew Owens moved the dates of Ana's task "Ship", and Ana was told/.test(await md()), 'the changelog (and so the Chat digest) has one line for it', '');
  // The off switch: the owner's, logged.
  r = await asPost(an, '/access', { requests: 'off' });
  ok(r.status === 403, 'only the plan\'s owner can turn requests off', r.status);
  r = await asPost(dr, '/access', { requests: 'off' });
  ok(r.status === 200 && r.j.requests === 'off', 'the owner turns them off', r.j);
  r = await opsQ(dr, [{ op: 'row', uid: ship.uid, set: { owner: 'Zed' } }]);
  ok((await exp()).items.find(x => x.uid === ship.uid).owner === 'Zed' && r.j.requested.length === 0, 'and an owner change is direct again', r.j);
  ok(/Drew Owens turned requests off for this plan/.test(await md()), 'which the changelog says', '');
  await asPost(dr, '/access', { requests: 'on' });
  stop(Q);
  }
  carbon.close();
  }

  // ── Presence counts open tabs (0.71.0): a tab that reconnects replaces its old entry,
  // and a stream whose tab stops checking in is closed and leaves the count.
  {
  const P = await start('presence', basePlan(false), null, 8946, { ADOM_GANTT_LIVE_STALE_MS: '1500' });
  const wait = ms => new Promise(r => setTimeout(r, ms));
  const open = async cid => {
    const r = await fetch(P.base + '/live?cid=' + cid); const rd = r.body.getReader();
    const done = (async () => { for (;;) { const x = await rd.read(); if (x.done) return true; } })().catch(() => true);
    return { done };
  };
  const ended = (s, ms = 1500) => Promise.race([s.done, wait(ms).then(() => false)]);
  const ping = async cid => (await post(P, '/live/ping?cid=' + cid, {})).j || {};
  const a1 = await open('tabA'); await wait(200);
  await open('tabA'); await wait(200);
  let pj = await ping('tabA');
  ok(pj.known === true && pj.tabs === 1, 'a tab that reconnects is one tab, not two', pj);
  ok(await ended(a1) === true, 'and its old connection is closed');
  const b = await open('tabB'); await wait(200);
  ok((await ping('tabB')).tabs === 2, 'a second tab counts');
  for (let i = 0; i < 7; i++) { await wait(450); await ping('tabA'); }
  pj = await ping('tabA');
  ok(pj.tabs === 1 && pj.known === true, 'a tab that stops checking in drops out of the count; one that pings stays', pj);
  ok(await ended(b) === true, 'and the quiet one\'s stream is closed');
  ok((await ping('tabB')).known === false, 'its next ping says it was forgotten, so the tab reopens its stream');
  ok((await fetch(P.base + '/live/ping?cid=tabA')).status === 405, 'ping is POST only');
  // What each tab is on (0.74.0), broadcast with the presence.
  const watch = await fetch(P.base + '/live?cid=watch'); const wr = watch.body.getReader(); let wtxt = '';
  const pump = (async () => { for (;;) { const x = await wr.read(); if (x.done) return; wtxt += Buffer.from(x.value).toString(); } })().catch(() => {});
  await wait(200);
  let fr = await post(P, '/live/focus?cid=tabA', { uids: ['00001', '00002', 'bad uid!', 7, '00001'] });
  ok(fr.status === 200 && fr.j.known === true && JSON.stringify(fr.j.uids) === '["00001","00002"]', 'a tab says which rows it is on; junk and repeats are dropped', fr.j);
  await wait(300);
  const lastPresence = () => { const m = wtxt.split('\n').filter(l => l.startsWith('data: ')).map(l => { try { return JSON.parse(l.slice(6)); } catch (e) { return null; } }).filter(x => x && x.type === 'presence').pop(); return m; };
  let lp = lastPresence();
  ok(lp && Array.isArray(lp.focus) && lp.focus.some(f => f.cid === 'tabA' && JSON.stringify(f.uids) === '["00001","00002"]'), 'every tab is told, with the presence', lp && lp.focus);
  await post(P, '/live/focus?cid=tabA', { uids: [] }); await wait(300);
  lp = lastPresence();
  ok(lp && !lp.focus.some(f => f.cid === 'tabA'), 'letting go clears it', lp && lp.focus);
  ok((await post(P, '/live/focus?cid=tabA', { uids: Array.from({ length: 40 }, (_, i) => String(10000 + i)) })).j.uids.length === 25, 'at most 25 rows per tab');
  ok((await fetch(P.base + '/live/focus?cid=tabA')).status === 405, 'focus is POST only');
  stop(P);
  }
  // ── Sections and the stored order (0.77.1) ──
  // On 2026-11-03 the team plan's stored order stopped following its sections (rows moved to
  // another section by their section field alone), every open page grouped it its own way,
  // and pages that disagreed corrected it back and forth. The server now keeps it grouped,
  // says so, and announces a batch's sections before its rows.
  {
  console.log('E. sections and the stored order');
  const secPlan = () => ({ project: { title: 'Sections', subtitle: 'server_test' },
    phases: ['A', 'B', 'C'].map(name => ({ name, start: '', end: '' })),
    items: [['00001', 'a0', 0], ['00002', 'a1', 0], ['00003', 'b0', 1], ['00004', 'b1', 1], ['00005', 'c0', 2]]
      .map(([uid, id, phase]) => ({ type: 'task', uid, id, name: 'Row ' + id, start: '2026-10-05', end: '2026-10-09', phase })) });
  const side = (order, stateRev) => ({ v: 4, items: [], deleted: [], added: [], order, stateRev,
    phases: ['A', 'B', 'C'].map((name, i) => ({ i, name, start: '', end: '', tags: [] })) });
  const Q = await start('sections', secPlan(), side(['00001', '00002', '00003', '00004', '00005'], 40), 8940);
  const auth = { Authorization: 'Bearer ' + Q.token };
  const ops = async list => (await post(Q, '/ops', { client: 't', batch: 'b' + (++opsN), ops: list }, auth)).j || {};
  let opsN = 0;
  const stored = () => JSON.parse(fs.readFileSync(path.join(Q.dir, 'plan-state.json'), 'utf8'));
  ok(stored().stateRev === 41, 'a start makes one revision, so every page that stayed open across it fetches the plan once', stored().stateRev);
  let j = await ops([{ op: 'row', uid: '00002', set: { phase: 2 } }]);
  let o = (j.ops || []).find(x => x.op === 'order');
  ok(o && o.grouped === true && eq(o.order, ['00001', '00003', '00004', '00002', '00005']), 'a row moved to another section by its section alone comes back in an order grouped by section, flagged as grouped', j.ops);
  ok(eq(stored().order, ['00001', '00003', '00004', '00002', '00005']), 'and that is the order the server keeps', stored().order);
  j = await ops([{ op: 'row', uid: '00001', set: { phase: 3 } }, { op: 'phases', phases: ['A', 'B', 'C', 'D'].map(name => ({ name, start: '', end: '' })) }]);
  ok(eq((j.ops || []).map(x => x.op), ['phases', 'row', 'order']), 'a batch that moves a row into a section it creates is announced sections first, then rows, then the order (a page takes them in that order)', (j.ops || []).map(x => x.op));
  let ex = await (await fetch(Q.base + '/export/json', { headers: auth })).json();
  ok(ex.items.find(r => r.uid === '00001').phase === 3 && ex.items[ex.items.length - 1].uid === '00001', 'the row is in the new section, and last, where its section is', ex.items.map(r => r.uid + ':' + r.phase).join(' '));
  j = await ops([{ op: 'move', uid: '00003', after: '00004' }]);
  o = (j.ops || []).find(x => x.op === 'order');
  ok(o && eq(o.order, ['00004', '00003', '00002', '00005', '00001']), 'a move inside a section lands as it was made', o && o.order);
  j = await ops([{ op: 'move', uid: '00005', after: null }]);
  o = (j.ops || []).find(x => x.op === 'order');
  ok(o && eq(o.order, ['00004', '00003', '00005', '00002', '00001']), 'a move to the top of the plan keeps the row in its own section, first in it', o && o.order);
  const revQ = stored().stateRev;
  j = await ops([{ op: 'move', uid: '00005', after: null }]);
  ok(j.stateRev === revQ && eq(j.ops, []) && stored().stateRev === revQ, 'a move that changes nothing makes no revision and is announced to nobody (a 0.77.0 page in the wrong sections sends them until reloaded)', j);
  stop(Q);
  const R = await start('sections-start', secPlan(), side(['00003', '00001', '00005', '00002', '00004'], 7), 8940);
  let st = JSON.parse(fs.readFileSync(path.join(R.dir, 'plan-state.json'), 'utf8'));
  ok(eq(st.order, ['00001', '00002', '00003', '00004', '00005']) && st.stateRev === 8, 'a server started on an order that is not grouped by section keeps it grouped from then on, as one new revision', [st.order, st.stateRev]);
  ex = await (await fetch(R.base + '/export/json', { headers: { Authorization: 'Bearer ' + R.token } })).json();
  ok(eq(ex.items.map(r => r.uid), ['00001', '00002', '00003', '00004', '00005']), 'and the merged plan reads it so', ex.items.map(r => r.uid));
  stop(R);
  const U = await start('sections-part', secPlan(), side(['00003', '00001'], 7), 8940);
  st = JSON.parse(fs.readFileSync(path.join(U.dir, 'plan-state.json'), 'utf8'));
  // 0.79.0: the order is derived from every row's key, so it is complete from the start; an
  // order that did not list every row was never the one the merge applied, and the rows keep
  // the order the merge did apply (the data file's).
  ok(eq(st.order, ['00001', '00002', '00003', '00004', '00005']) && st.stateRev === 8, 'an order that does not list every row: the rows keep the order the merge showed, and the stored order is complete from then on', [st.order, st.stateRev]);
  stop(U);
  }
  // ── G: subtasks (0.79.0): a parent and a key on each row; the order is derived ──
  {
  console.log('G. subtasks: parents, keys and the derived order');
  const treePlan = () => ({ project: { title: 'Tree', subtitle: 'server_test' },
    phases: ['A', 'B', 'C'].map(name => ({ name, start: '', end: '' })),
    items: [['00001', 't1', 0], ['00002', 't2', 0], ['00003', 't3', 0], ['00004', '', 0, 'milestone'], ['00005', 'b1', 1], ['00006', 'b2', 1], ['00007', 'c1', 2]]
      .map(([uid, id, phase, type]) => type === 'milestone' ? { type, uid, name: 'Gate', date: '2026-10-20', phase }
        : { type: 'task', uid, id, name: 'Row ' + id, start: '2026-10-05', end: '2026-10-09', phase, ...(uid === '00007' ? { deps: ['t1'], tags: ['Fab'] } : {}) }) });
  const Q = await start('tree', treePlan(), null, 8940);
  const auth = { Authorization: 'Bearer ' + Q.token };
  let opsN = 0;
  const ops = async list => (await post(Q, '/ops', { client: 't', batch: 'f' + (++opsN), ops: list }, auth)).j || {};
  const stored = () => JSON.parse(fs.readFileSync(path.join(Q.dir, 'plan-state.json'), 'utf8'));
  const exp = async () => (await fetch(Q.base + '/export/json', { headers: auth })).json();
  const row = async uid => (await exp()).items.find(r => r.uid === uid);
  const keyOf = uid => { const o = (stored().items || []).find(x => x.uid === uid); return o && o.pos; };
  let st = stored();
  const keyed = ['00001', '00002', '00003', '00004', '00005', '00006', '00007'].map(keyOf);
  ok(st.stateRev === 1 && keyed.every(k => typeof k === 'string' && /^[A-Za-z][0-9A-Za-z]*$/.test(k)) && eq(st.order, ['00001', '00002', '00003', '00004', '00005', '00006', '00007']),
    'a plan with no sidecar gets one at start: every row a key, in the order it was shown, as one revision', [st.stateRev, keyed, st.order]);
  ok(keyed[0] < keyed[1] && keyed[1] < keyed[2] && keyed[2] < keyed[3] && keyed[4] < keyed[5], 'the keys sort as the rows were listed, per section', keyed);
  const e1 = (stored().items || []).find(x => x.uid === '00001') || {}, e7 = (stored().items || []).find(x => x.uid === '00007') || {};
  ok(e1.pos && e1.name === 'Row t1' && 'owner' in e1 && eq(e7.deps, ['t1']) && eq(e7.tags, ['Fab']), 'a row the start gives a key gets a whole entry, its lists included, the way an edit makes one (a 0.77 server merges a list onto the entry\'s own after a rollback)', [e1, e7]);
  let j0 = await ops([{ op: 'row', uid: '00007', add: { deps: ['t2'], tags: ['Ops'] } }]);
  let r0 = await row('00007');
  ok(eq(r0.deps, ['t1', 't2']) && eq(r0.tags, ['Fab', 'Ops']), 'a list added to on a row the sidecar had only placed keeps the data file\'s own items (found by the 0.77 docs and fuzz scenarios)', [r0.deps, r0.tags, j0.ops]);
  let j = await ops([{ op: 'row', uid: '00002', set: { parent: '00001', pos: 'a0' } }]);
  let r = await row('00002');
  ok(j.ok && r.parent === '00001' && r.pos === 'a0' && eq(j.ops, [{ op: 'row', uid: '00002', set: { parent: '00001', pos: 'a0' } }]), 'a row nested under a task holds its parent and key; the flat order did not change, so it is not announced', j.ops);
  j = await ops([{ op: 'row', uid: '00003', set: { parent: '00004', pos: 'a5', owner: 'Ana' } }]);
  r = await row('00003');
  ok(!r.parent && r.owner === 'Ana' && (j.refused || []).some(x => /00003: place/.test(x)), 'a milestone cannot hold subtasks: the place is refused, the rest of the op lands', [r.parent, r.owner, j.refused]);
  j = await ops([{ op: 'row', uid: '00001', set: { parent: '00002', pos: 'a0' } }]);
  ok(!(await row('00001')).parent && (j.refused || []).length === 1, 'a row cannot go under its own subtask (no loops)', j);
  j = await ops([{ op: 'row', uid: '00003', set: { parent: '00002', pos: 'a0' } }, { op: 'row', uid: '00005', set: { parent: '00003', pos: 'a0' } }]);
  r = await row('00005');
  ok(r.parent === '00003' && r.phase === 0 && (j.ops || []).some(o => o.op === 'row' && o.uid === '00005' && o.set.phase === 0), 'three levels deep: a row nested from another section takes its new top-level task\'s section, and says so', [r.parent, r.phase, j.ops]);
  j = await ops([{ op: 'row', uid: '00006', set: { parent: '00005', pos: 'a0' } }]);
  ok(!(await row('00006')).parent && (j.refused || []).length === 1, 'a fourth level is refused (ClickUp\'s default depth)', j);
  ex = await exp();
  ok(eq(ex.items.map(x => x.uid), ['00001', '00002', '00003', '00005', '00004', '00006', '00007']), 'the merged plan lists each task followed by its subtasks, depth first', ex.items.map(x => x.uid));
  j = await ops([{ op: 'row', uid: '00001', set: { parent: '', pos: 'a0', phase: 2 } }]);
  ex = await exp();
  ok(['00001', '00002', '00003', '00005'].every(u => ex.items.find(x => x.uid === u).phase === 2) && ['00002', '00003', '00005'].every(u => (j.ops || []).some(o => o.op === 'row' && o.uid === u && o.set.phase === 2)),
    'a parent moved to another section takes its whole subtree, and every subtask\'s section is announced', j.ops);
  j = await ops([{ op: 'del', uid: '00002' }]);
  r = await row('00003');
  ok(r.parent === '00001' && (await row('00005')).parent === '00003' && (j.ops || []).some(o => o.op === 'row' && o.uid === '00003' && o.set.parent === '00001'),
    'a parent deleted on its own (a page that knew nothing of its subtasks): its subtask takes its place, with its own subtree', j.ops);
  j = await ops([{ op: 'row', uid: '00006', set: { parent: '00002', pos: 'a1' } }]);
  r = await row('00006');
  ok(r.parent === '00001' && r.phase === 2, 'nesting under a row somebody deleted puts the row where the deleted row was', [r.parent, r.phase, j.ops]);
  j = await ops([{ op: 'add', row: { uid: '00002' } }]);
  ok((await row('00002')) && (await row('00003')).parent === '00001', 'putting the deleted parent back does not take back the rows that took its place', j.ops);
  // A 0.77 page: moves by "after that row", sections by the section field alone.
  ex = await exp();
  const order = ex.items.map(x => x.uid);
  j = await ops([{ op: 'move', uid: '00005', after: '00007' }]);
  r = await row('00005');
  ok(r.parent !== '00003' && eq((await exp()).items.map(x => x.uid).indexOf('00005'), (await exp()).items.map(x => x.uid).indexOf('00007') + 1), 'a 0.77 page\'s move out of a task\'s subtasks lands where it put it, out from under the task', [order, (await exp()).items.map(x => x.uid + ':' + (x.parent || ''))]);
  const kids = (await exp()).items.filter(x => x.parent === '00001').map(x => x.uid);
  if (kids.length >= 2) {
    j = await ops([{ op: 'move', uid: kids[kids.length - 1], after: '00001' }]);
    const k2 = (await exp()).items.filter(x => x.parent === '00001').map(x => x.uid);
    ok(k2[0] === kids[kids.length - 1] && k2.length === kids.length, 'a 0.77 page\'s move inside a task\'s subtasks keeps the row under the task', [kids, k2]);
  } else ok(false, 'setup: two subtasks under 00001', kids);
  const revQ = stored().stateRev, flat = (await exp()).items.map(x => x.uid);
  j = await ops([{ op: 'move', uid: flat[3], after: flat[2] }]);
  ok(j.stateRev === revQ && eq(j.ops, []), 'a 0.77 move that changes nothing still makes no revision', j);
  j = await ops([{ op: 'row', uid: '00003', set: { phase: 1 } }]);
  r = await row('00003');
  ok(!r.parent && r.phase === 1 && (await row('00005')).phase !== undefined, 'a 0.77 page moving a subtask to another section takes it out from under its task, into that section', [r.parent, r.phase, j.ops]);
  // Two rows given the same key at once: the second moves just after the first.
  const tops = (await exp()).items.filter(x => !x.parent), sec = [0, 1, 2].find(p => tops.filter(x => x.phase === p).length >= 2);
  const top = tops.filter(x => x.phase === sec).map(x => x.uid);
  j = await ops([{ op: 'row', uid: top[0], set: { parent: '', pos: 'a9', phase: sec } }, { op: 'row', uid: top[1], set: { parent: '', pos: 'a9', phase: sec } }]);
  const k0 = keyOf(top[0]), k1 = keyOf(top[1]);
  ok(top.length >= 2 && k0 && k1 && k0 !== k1 && (j.ops || []).some(o => o.uid === top[1] && o.set.pos === k1), 'two siblings given one key: the one placed later gets a key of its own, announced', [top, k0, k1]);
  // A whole-state save from a page or script that predates parents and keys keeps them.
  const before = await exp();
  const raw = stored();
  const old = { v: 4, items: (raw.items || []).map(o => { const c = Object.assign({}, o); delete c.parent; delete c.pos; return c; }), added: raw.added, deleted: raw.deleted, phases: raw.phases, order: before.items.map(x => x.uid) };
  const sv = await post(Q, '/save', old, auth);
  const after = await exp();
  ok(sv.status === 200 && eq(after.items.map(x => x.uid + ':' + (x.parent || '')), before.items.map(x => x.uid + ':' + (x.parent || ''))), 'a whole-state save that knows nothing of parents and keys keeps every row\'s place', [sv.status, after.items.map(x => x.uid + ':' + (x.parent || ''))]);
  // ...and the order it carries says what it moved: a top-level row moved to the top of its
  // section lands there, its subtasks with it.
  const tl = after.items.filter(x => !x.parent), sec2 = [0, 1, 2].find(p => tl.filter(x => x.phase === p).length >= 2);
  const mover = tl.filter(x => x.phase === sec2).slice(-1)[0], kidsM = after.items.filter(x => x.parent === mover.uid).map(x => x.uid);
  const ord2 = after.items.map(x => x.uid).filter(u => u !== mover.uid), first = ord2.findIndex(u => (after.items.find(x => x.uid === u) || {}).phase === sec2);
  ord2.splice(first, 0, mover.uid);
  const raw2 = stored();
  const sv2 = await post(Q, '/save', { v: 4, items: (raw2.items || []).map(o => { const c = Object.assign({}, o); delete c.parent; delete c.pos; return c; }), added: raw2.added, deleted: raw2.deleted, phases: raw2.phases, order: ord2 }, auth);
  const after2 = await exp(), firstIn = after2.items.find(x => x.phase === sec2 && !x.parent);
  ok(sv2.status === 200 && firstIn && firstIn.uid === mover.uid && kidsM.every(u => (after2.items.find(x => x.uid === u) || {}).parent === mover.uid), 'a whole-state save that moved a row: the row lands where that save put it, its subtasks with it', [sv2.status, after2.items.map(x => x.uid + ':' + (x.parent || '') + '@' + x.phase)]);
  const csv = await (await fetch(Q.base + '/export/csv', { headers: auth })).text();
  const lines = csv.split('\n'), hd = lines[0].split(',');
  const cRow = lines.find(l => l.startsWith('00005,'));
  ok(hd[0] === 'uid' && hd[1] === 'parent' && cRow && cRow.split(',')[1] === ((await row('00005')).parent || ''), 'the CSV export carries a parent column, the parent\'s uid', [lines[0], cRow]);
  stop(Q);
  // An import names parents by uid or task id; one it cannot hold is dropped, never an error.
  const I = await start('tree-import', treePlan(), null, 8940);
  const imp = 'uid,parent,id,name,start,end,phase,type,date\n00010,,p1,Parent,2026-10-05,2026-10-09,A,task,\n00011,p1,k1,Kid,2026-10-05,2026-10-09,B,task,\n00012,00011,g1,Grandkid,2026-10-05,2026-10-09,A,task,\n00013,00099,x1,Orphan,2026-10-05,2026-10-09,A,task,\n00014,,m1,Gate,,,A,milestone,2026-10-20\n00015,00014,k2,Under a gate,2026-10-05,2026-10-09,A,task,';
  const ir = await post(I, '/import/csv', imp, Object.assign({ 'Content-Type': 'text/csv' }, { Authorization: 'Bearer ' + I.token }));
  const ie = await (await fetch(I.base + '/export/json', { headers: { Authorization: 'Bearer ' + I.token } })).json();
  const g = u => ie.items.find(x => x.uid === u) || {};
  ok(ir.status === 200 && g('00011').parent === '00010' && g('00012').parent === '00011' && g('00011').phase === g('00010').phase && !g('00013').parent && !g('00015').parent,
    'a CSV import reads parents by task id or uid, puts a subtask in its parent\'s section, and drops a parent that is missing or a milestone', [ir.status, ie.items.map(x => x.uid + '<' + (x.parent || '') + '@' + x.phase)]);
  ok(ie.items.every(x => typeof x.pos === 'string' && x.pos), 'and every imported row has a key', ie.items.map(x => x.pos));
  stop(I);
  // A rollback: 0.77.1's server (from git, when this is a checkout) on a sidecar this one wrote.
  // It merges a list op onto the row's entry, so the entries this server makes to place rows
  // must carry the row's lists, or the data file's dependencies and tags would be dropped.
  let oldSrv = '';
  try { oldSrv = execFileSync('git', ['-C', path.join(HERE, '..'), 'show', 'ffdacb4:server.js'], { encoding: 'utf8', maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (e) {}
  if (!oldSrv) console.log('  skip a rollback to 0.77.1: no git checkout to read its server from');
  else {
    const R1 = await start('tree-rollback', treePlan(), null, 8940);
    stop(R1); await new Promise(r => setTimeout(r, 400));
    const oldDir = path.join(TMP, 'old-0771'); fs.mkdirSync(oldDir, { recursive: true }); fs.writeFileSync(path.join(oldDir, 'server.js'), oldSrv);
    const port = await freePort(8940);
    const op = spawn(process.execPath, [path.join(oldDir, 'server.js'), '--data', R1.data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });
    procs.push(op);
    const base = `http://127.0.0.1:${port}`;
    for (let i = 0; i < 100; i++) { try { if ((await fetch(base + '/health')).ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 100)); }
    const tok = fs.readFileSync(path.join(R1.dir, 'plan-agent-token'), 'utf8').trim();
    await fetch(base + '/ops', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tok }, body: JSON.stringify({ ops: [{ op: 'row', uid: '00007', add: { deps: ['t2'], tags: ['Ops'] } }] }) });
    const rb = ((await (await fetch(base + '/export/json', { headers: { Authorization: 'Bearer ' + tok } })).json()).items || []).find(x => x.uid === '00007') || {};
    ok(eq(rb.deps, ['t1', 't2']) && eq(rb.tags, ['Fab', 'Ops']), 'after a rollback to 0.77.1, a list op on a row this server placed keeps the row\'s own items', [rb.deps, rb.tags]);
    try { op.kill('SIGTERM'); } catch (e) {}
  }
  }

  // ── F: milestone ids and dependencies to and from milestones (0.78.0) ──
  // A milestone had no id, so a dependency could not name it: nothing could wait on a gate
  // and a gate could wait on nothing. Every milestone gets an id now, at startup, at import
  // and when it is made, and its dependencies go through ops, exports and imports like a task's.
  {
  console.log('F. milestone ids and dependencies');
  const msPlan = () => ({ project: { title: 'Gates', subtitle: 'server_test' },
    phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-12' }],
    items: [
      { type: 'task', uid: '00001', id: 'evt-build', name: 'EVT build', start: '2026-10-05', end: '2026-10-09', phase: 0 },
      { type: 'task', uid: '00002', id: 'evt-test', name: 'EVT test', start: '2026-10-12', end: '2026-10-16', phase: 0, deps: ['evt-build'] },
      { type: 'milestone', uid: '00003', name: 'EVT sign-off', date: '2026-10-16', phase: 0 },
      { type: 'task', uid: '00004', id: 'tooling', name: 'Production tooling', start: '2026-10-19', end: '2026-10-30', phase: 1 },
      // A task already holds this milestone's slug, so the milestone is numbered on.
      { type: 'task', uid: '00005', id: 'launch', name: 'Launch prep', start: '2026-11-02', end: '2026-11-06', phase: 1 },
      { type: 'milestone', uid: '00006', name: 'Launch', date: '2026-11-09', phase: 1 },
      // One that came with an id keeps it.
      { type: 'milestone', uid: '00007', id: 'keep-me', name: 'Board review', date: '2026-10-21', phase: 1 },
    ], });
  const msSide = { v: 4, items: [], deleted: [], order: [], stateRev: 5,
    added: [{ type: 'milestone', uid: '00008', name: 'EVT sign-off', date: '2026-10-23', phase: 1 }] };
  const M = await start('milestones', msPlan(), msSide, 8940);
  const auth = { Authorization: 'Bearer ' + M.token };
  let opsN = 0;
  const ops = async list => (await post(M, '/ops', { client: 't', batch: 'm' + (++opsN), ops: list }, auth)).j || {};
  const exp = async () => (await fetch(M.base + '/export/json', { headers: auth })).json();
  const onDisk = () => JSON.parse(fs.readFileSync(M.data, 'utf8'));
  const sideOf = () => JSON.parse(fs.readFileSync(path.join(M.dir, 'plan-state.json'), 'utf8'));
  const logOf = () => fs.readFileSync(path.join(M.dir, 'plan-changelog.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
  const ms = (d, uid) => d.items.find(r => r.uid === uid);
  let d = onDisk();
  ok(ms(d, '00003').id === 'evt-sign-off' && ms(d, '00006').id === 'launch-2', 'every milestone in the data file gets an id at startup, made from its name the way a task\'s is, numbered on when the plan already has it', [ms(d, '00003').id, ms(d, '00006').id]);
  ok(ms(d, '00007').id === 'keep-me', 'a milestone that came with an id keeps it', ms(d, '00007').id);
  let sd = sideOf();
  ok(sd.added[0].id === 'evt-sign-off-2' && sd.stateRev >= 6, 'a created milestone in the sidecar gets one too, unique across the whole plan, as a new revision', [sd.added[0].id, sd.stateRev]);
  const ev = logOf().filter(e => e.action === 'milestone.ids');
  ok(ev.length === 1 && ev[0].count === 3, 'the changelog says so once, with the count', ev);
  ok((await getT(M, '/changelog?format=md')).includes('gave 3 milestone(s) a permanent id'), 'in its own words', '');
  // The same files, served again: nothing left to give.
  const dataBytes = fs.readFileSync(M.data, 'utf8');
  const gone = new Promise(r => { if (M.proc.exitCode !== null || M.proc.signalCode !== null) r(); else M.proc.once('exit', r); });
  stop(M); await gone;
  const again = spawn(process.execPath, [SERVER, '--data', M.data, '--port', String(M.port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });
  procs.push(again);
  for (let i = 0; i < 100; i++) { try { const r = await fetch(M.base + '/health'); if (r.ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 100)); }
  ok(fs.readFileSync(M.data, 'utf8') === dataBytes && logOf().filter(e => e.action === 'milestone.ids').length === 1, 'a restart finds every milestone with an id and changes nothing', '');

  // Dependencies through ops: a milestone waits on tasks, a task waits on a milestone.
  let j = await ops([{ op: 'row', uid: '00003', add: { deps: ['evt-test'] } }]);
  ok(j.ops && j.ops[0] && eq(j.ops[0].set, { deps: ['evt-test'] }), 'a milestone takes a dependency over /ops and announces it as stored', j);
  j = await ops([{ op: 'row', uid: '00004', set: { deps: ['evt-sign-off'] } }]);
  let ex = await exp();
  ok(eq(ms(ex, '00003').deps, ['evt-test']) && eq(ms(ex, '00004').deps, ['evt-sign-off']), 'the merged plan has the gate waiting on the test and the tooling waiting on the gate', [ms(ex, '00003').deps, ms(ex, '00004').deps]);
  // Two people add a different dependency to one milestone at the same moment: both stand.
  await ops([{ op: 'row', uid: '00006', add: { deps: ['launch'] } }]);
  await ops([{ op: 'row', uid: '00006', add: { deps: ['keep-me'] } }]);
  ex = await exp();
  ok(eq(ms(ex, '00006').deps, ['launch', 'keep-me']), 'two people add a different dependency to one milestone: both are kept, item by item', ms(ex, '00006').deps);
  j = await ops([{ op: 'row', uid: '00006', del: { deps: ['launch'] } }]);
  ok(eq((await exp()).items.find(r => r.uid === '00006').deps, ['keep-me']), 'and one can be taken out again', j);
  // A milestone made by a page: its id made unique, or given when it has none.
  j = await ops([{ op: 'add', row: { type: 'milestone', uid: '00020', id: 'launch', name: 'Launch', date: '2026-11-10', phase: 1, deps: ['tooling'] } }]);
  ok(j.ops && j.ops[0].row && j.ops[0].row.id === 'launch-3' && eq(j.ops[0].row.deps, ['tooling']), 'a new milestone lands with its id made unique and its dependencies', j.ops);
  j = await ops([{ op: 'add', row: { type: 'milestone', uid: '00021', name: 'Ship it!', date: '2026-11-12', phase: 1 } }]);
  ok(j.ops && j.ops[0].row && j.ops[0].row.id === 'ship-it', 'a new milestone sent without an id (a page from before 0.78.0) is given one from its name', j.ops);
  // A deleted milestone takes the dependencies on it with it, everywhere the plan is read.
  await ops([{ op: 'del', uid: '00003' }]);
  ex = await exp();
  ok(!ms(ex, '00003') && eq(ms(ex, '00004').deps || [], []), 'deleting a milestone drops the dependencies that named it from the merged plan', ms(ex, '00004'));
  await ops([{ op: 'add', row: { uid: '00003' } }]);
  ex = await exp();
  ok(ms(ex, '00003') && ms(ex, '00003').id === 'evt-sign-off' && eq(ms(ex, '00004').deps, ['evt-sign-off']), 'and putting it back (an undo) brings them back with its id', [ms(ex, '00003'), ms(ex, '00004').deps]);

  // Exports and imports carry them.
  const csv = await getT(M, '/export/csv');
  const head = csv.split('\n')[0].split(','), line = csv.split('\n').find(l => l.includes('EVT sign-off,'));
  ok(line && line.split(',')[head.indexOf('id')] === 'evt-sign-off' && line.split(',')[head.indexOf('deps')] === 'evt-test', 'the CSV carries a milestone\'s id and its dependencies in the same columns a task\'s go in', line);
  let imp = await post(M, '/import/csv', csv, { 'Content-Type': 'text/csv' });
  ex = await exp();
  const byNm = n => ex.items.find(r => r.name === n);
  ok(imp.status === 200 && byNm('EVT sign-off').id === 'evt-sign-off' && eq(byNm('EVT sign-off').deps, ['evt-test']) && eq(byNm('Production tooling').deps, ['evt-sign-off']), 'and an export imported back keeps both directions of the link', [byNm('EVT sign-off'), byNm('Production tooling')]);
  const j1 = await exp();
  imp = await post(M, '/import/json', j1);
  const j2 = await exp();
  ok(imp.status === 200 && eq(j1, j2), 'a JSON export imported back is identical, milestone ids and dependencies included', '');
  imp = await post(M, '/import/csv', 'type,name,date,start,end,deps\nmilestone,Gate one,2026-10-10,,,\ntask,After gate,,2026-10-12,2026-10-14,gate-one\n', { 'Content-Type': 'text/csv' });
  ex = await exp();
  ok(imp.status === 200 && ex.items[0].id === 'gate-one' && eq(ex.items[1].deps, ['gate-one']), 'a CSV milestone with no id column gets one from its name, which a task in the file can already name', ex.items);
  const xml = '<Project><Tasks>'
    + '<Task><UID>1</UID><Name>Build</Name><Start>2026-10-01T08:00:00</Start><Finish>2026-10-05T17:00:00</Finish></Task>'
    + '<Task><UID>2</UID><Name>Gate</Name><Start>2026-10-06T08:00:00</Start><Finish>2026-10-06T08:00:00</Finish><Milestone>1</Milestone><PredecessorLink><PredecessorUID>1</PredecessorUID></PredecessorLink></Task>'
    + '<Task><UID>3</UID><Name>Ship</Name><Start>2026-10-07T08:00:00</Start><Finish>2026-10-09T17:00:00</Finish><Milestone>0</Milestone><PredecessorLink><PredecessorUID>2</PredecessorUID></PredecessorLink></Task>'
    + '</Tasks></Project>';
  imp = await post(M, '/import/msproject-xml', xml, { 'Content-Type': 'application/xml' });
  ex = await exp();
  const gate = ex.items.find(r => r.name === 'Gate'), ship = ex.items.find(r => r.name === 'Ship');
  ok(imp.status === 200 && gate.type === 'milestone' && gate.date === '2026-10-06' && eq(gate.deps, ['task-1']) && eq(ship.deps, [gate.id]) && gate.id === 'ms-2', 'MS Project: a milestone comes in as one, and keeps its links in both directions', [gate, ship.deps]);
  const jira = 'Summary,Issue key,Issue Type,Status,Created,Due Date,Inward issue link (Blocks),Inward issue link (Blocks),Outward issue link (Blocks)\n'
    + 'Build it,GT-1,Task,To Do,2026-10-01,2026-10-05,,,GT-9\n'
    + 'Test it,GT-2,Task,To Do,2026-10-06,2026-10-08,,,\n'
    + 'Release gate,GT-3,Milestone,To Do,2026-10-01,2026-10-09,GT-1,GT-2,GT-4\n'
    + 'Ship it,GT-4,Story,To Do,2026-10-12,2026-10-14,,,\n';
  imp = await post(M, '/import/jira-csv', jira, { 'Content-Type': 'text/csv' });
  ex = await exp();
  const rg = ex.items.find(r => r.name === 'Release gate'), sh = ex.items.find(r => r.name === 'Ship it'), bi = ex.items.find(r => r.name === 'Build it');
  ok(imp.status === 200 && rg.type === 'milestone' && rg.date === '2026-10-09' && eq(rg.deps, ['gt-1', 'gt-2']) && eq(sh.deps, ['gt-3']), 'Jira: a Milestone issue comes in as a milestone; Blocks links come in as dependencies, into it and out of it', [rg, sh.deps]);
  ok(!bi.deps, 'and a link to an issue the file does not have is left out', bi);
  // A description over two lines ahead of the links: the links are read from the same rows
  // as everything else, so they still land on the issue they belong to.
  const jira2 = 'Summary,Issue key,Issue Type,Status,Created,Due Date,Inward issue link (Blocks),Inward issue link (Blocks),Outward issue link (Blocks),Description\n'
    + 'Build it,GT-1,Task,To Do,2026-10-01,2026-10-05,,,GT-9,\n'
    + 'Test it,GT-2,Task,To Do,2026-10-06,2026-10-08,,,,"Run the bench test.\nThen the soak."\n'
    + 'Release gate,GT-3,Milestone,To Do,2026-10-01,2026-10-09,GT-1,GT-2,GT-4,\n'
    + 'Ship it,GT-4,Story,To Do,2026-10-12,2026-10-14,,,,\n';
  imp = await post(M, '/import/jira-csv', jira2, { 'Content-Type': 'text/csv' });
  ex = await exp();
  const rg2 = ex.items.find(r => r.name === 'Release gate') || {}, ti2 = ex.items.find(r => r.name === 'Test it') || {};
  ok(imp.status === 200 && eq(rg2.deps, ['gt-1', 'gt-2']) && eq((ex.items.find(r => r.name === 'Ship it') || {}).deps, ['gt-3']) && ti2.description === 'Run the bench test.\nThen the soak.',
    'Jira: a two-line description does not shift the links onto the wrong issue', [imp.status, imp.t && imp.t.slice(0, 200), rg2, ti2]);

  // A milestone appended to the data file under a running server, and one saved by a script.
  const dd = onDisk();
  dd.items.push({ type: 'milestone', name: 'Appended gate', date: '2026-10-20', phase: 0 });
  fs.writeFileSync(M.data, JSON.stringify(dd, null, 2));
  j = (await post(M, '/uid/backfill', {}, auth)).j;
  ok(j && j.ok && j.milestoneIds === 1 && onDisk().items.find(r => r.name === 'Appended gate').id === 'appended-gate', 'the backfill gives an appended milestone its id along with its uid', j);
  const rev = (await getJ(M, '/health')).stateRev;
  const saved = await post(M, '/save', { v: 4, baseRev: rev, items: [], deleted: [], order: [], added: [{ type: 'milestone', uid: '00090', name: 'Scripted gate', date: '2026-10-21', phase: 0 }] });
  ok(saved.status === 200 && sideOf().added[0].id === 'scripted-gate', 'a created milestone saved without an id is given one', sideOf().added);
  }
}

// ── ClickUp import: the three doors, the report, and the token rules ──
// The fixtures are synthetic files in ClickUp's real shapes (test/fixtures): a workspace
// export written from GMT+5:30, a Folder's view export with ISO dates, and the API's
// answer for a List. The live API is a stand-in on loopback.
async function clickupSection() {
  console.log('F. ClickUp import');
  const FIX = path.join(HERE, 'fixtures');
  const read = f => fs.readFileSync(path.join(FIX, f), 'utf8');
  const names = (d, ph) => d.items.filter(r => ph === undefined || r.phase === ph).map(r => r.name);
  // A plan's rows with the uids taken out and each parent named instead, so two imports of
  // the same tasks compare equal although each numbered its rows afresh.
  const shape = d => JSON.stringify(d.items.map(({ uid, parent, ...x }) => Object.assign(x, parent ? { parent: (d.items.find(y => y.uid === parent) || {}).name } : {})));
  const C = await start('clickup', basePlan(false), null, 8940);
  const auth = { Authorization: 'Bearer ' + C.token };
  const before = await getJ(C, '/export/json');
  const oldUids = before.items.map(r => r.uid);

  // The workspace export (Settings, Imports and Exports).
  const DAY = '2026-11-03';   // the import's day, as the page sends it
  let r = await post(C, '/import/clickup-csv?today=' + DAY, read('clickup-workspace.csv'), Object.assign({ 'Content-Type': 'text/csv' }, auth));
  ok(r.status === 200 && r.j && r.j.ok && r.j.items === 20 && r.j.phases === 3, '/import/clickup-csv takes a ClickUp workspace export: 20 rows in 3 sections', r.t.slice(0, 300));
  let ex = await getJ(C, '/export/json');
  ok(eq(ex.phases.map(p => p.name), ['Sensor Board · EVT Build', 'Sensor Board · Firmware', 'Launch']), 'a section per List, "Folder · List", in the order they came', ex.phases.map(p => p.name));
  ok(eq(ex.phases.map(p => [p.start, p.end]), [['2026-09', '2026-11'], ['2026-10', '2026-11'], ['2026-11', '2026-12']]), 'each section spans its own rows', ex.phases);
  ok(ex.project.title === 'Hardware' && /workspace export/.test(ex.project.subtitle), 'the plan is named after the one Space everything shares', ex.project);
  ok(eq(names(ex, 0), ['Finalize schematic', 'Order long-lead parts', 'Lay out the board', 'Place components', 'Route power planes', 'Check return paths',
    'EVT build at the fab', 'Bring-up and test', 'Power-on smoke test', 'Sensor calibration', 'EVT complete', 'Write the EVT report', 'Old enclosure concept', 'Rework footprint for J3']),
    'file order, each subtask right after its parent and depth first, though the file lists a subtask before its parent; an orphan stays where it was', names(ex, 0));
  const w = n => byName(ex, n) || {};
  ok(w('Finalize schematic').start === '2026-10-05' && w('Finalize schematic').end === '2026-10-09', 'a date with no time is the exporter\'s day: 04:00 at GMT+5:30 is the evening before in UTC', w('Finalize schematic'));
  ok(w('Order long-lead parts').start === '2026-10-16' && w('Order long-lead parts').end === '2026-10-16', 'only a due date: the bar starts on it');
  ok(w('Customer training').start === '2026-12-07' && w('Customer training').end === '2026-12-07', 'only a start date: the bar ends on it');
  ok(w('Power management').start === '2026-11-13', 'a due date with a time of day keeps its day', w('Power management'));
  ok(w('Bring-up and test').start === '2026-11-16' && w('Bring-up and test').end === '2026-11-20', 'a parent with no dates spans its subtasks', w('Bring-up and test'));
  ok(w('Place components').parent === w('Lay out the board').uid && w('Route power planes').parent === w('Lay out the board').uid && w('Check return paths').parent === w('Route power planes').uid
    && w('Sensor calibration').parent === w('Bring-up and test').uid && !('parent' in w('Lay out the board')) && !('parent' in w('Rework footprint for J3')) && typeof w('Place components').pos === 'string',
    'each subtask names its parent row by uid (the subtasks model) and gets its place key from the server; a top-level row and an orphan name no parent', ex.items.map(x => [x.name, x.parent]));
  ok(w('Write the EVT report').start === DAY && w('Write the EVT report').end === DAY && eq(w('Write the EVT report').tags, ['undated']) && w('Write the EVT report').status === 'not-started',
    'a task with no date at all is a one-day bar on the import\'s day, tagged undated', w('Write the EVT report'));
  ok(names(ex).filter(n => n === 'Order long-lead parts').length === 1 && w('Order long-lead parts').owner === 'Owen Price'
    && w('Order long-lead parts').description === 'Also assigned in ClickUp: Raj Patel.', 'a task repeated on a second row is one row; the first assignee owns it, the rest are in its notes', w('Order long-lead parts'));
  ok(w('Sensor calibration').owner === '[email protected]', 'an assignee ClickUp writes as an email is the owner as written');
  ok(w('Finalize schematic').description === 'Lock the netlist for EVT.\nReview with Owen, then freeze the "rev B" schematic, and tag it.', 'a description with a line break, commas and quotes comes across whole', w('Finalize schematic').description);
  ok(eq(['Place components', 'Order long-lead parts', 'Check return paths', 'EVT build at the fab', 'Old enclosure concept', 'Power management', 'Pilot run plan'].map(n => w(n).status),
    ['complete', 'in-progress', 'not-started', 'blocked', 'cancelled', 'in-progress', 'not-started']), 'statuses: complete, in progress, to do, waiting on parts, won\'t do, an unknown name, Open');
  ok(w('Lay out the board').effort === 40 && w('Check return paths').effort === 2 && !('effort' in w('EVT build at the fab')), 'time estimates are effort in hours; none is none');
  ok(eq(w('Bootloader').tags, ['firmware']) && eq(w('Order long-lead parts').tags, ['long-lead', 'purchasing']), 'tags come across');
  ok(w('EVT complete').type === 'milestone' && w('EVT complete').date === '2026-11-20' && w('EVT complete').id === 'cu-86b4p3e8k' && !('effort' in w('EVT complete')), 'the Milestone task type is a milestone on its due date, with an id like a task\'s', w('EVT complete'));
  ok(eq(w('Bootloader').provenance, { label: 'ClickUp 86b4p5h2p', url: 'https://app.clickup.com/t/86b4p5h2p' }) && w('Bootloader').id === 'cu-86b4p5h2p', 'each row links back to its ClickUp task');
  ok(new Set(ex.items.map(x => x.uid)).size === 20 && !ex.items.some(x => oldUids.includes(x.uid)), 'every row gets a fresh uid, none of the replaced plan\'s', ex.items.map(x => x.uid));
  let rp = r.j.report || {};
  ok(rp.from === 'ClickUp workspace export (CSV)' && rp.read === 20 && rp.tasks === 18 && rp.milestones === 2 && rp.subtasks === 5 && rp.undated === 1 && rp.undatedOn === DAY
    && !('left' in rp) && !('leftCount' in rp), 'the report counts what came across, the undated row among it; nothing is left out', rp);
  ok(/^Not yet checked against a real ClickUp project\./.test(rp.unchecked || ''), 'and it says, calmly, that the importer has not been checked against a real ClickUp project', rp.unchecked);
  const drop = (rr, what) => (rr.dropped || []).find(d => d.what === what) || {};
  ok(drop(rp, 'Priority').count === 6 && drop(rp, 'Priority').detail === 'urgent 1, high 3, normal 1, low 1', 'priorities are counted, by level', rp.dropped);
  ok(['Checklists', 'Comments', 'Attachments', 'Time tracked', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'so are checklists, comments, attachments, time tracked and tasks in other Lists', rp.dropped);
  ok(drop(rp, 'Dependencies').count === 0 && /do not include them/.test(drop(rp, 'Dependencies').detail || ''), 'and it says a CSV export carries no dependencies, and where to get them');
  ok((rp.statuses || []).some(s => s.name === 'spec' && s.to === 'in-progress' && s.by === 'guess') && rp.changed.some(s => /"spec"/.test(s)), 'an unknown status name is read as in progress and said to be a guess', rp.statuses);
  ok(['1 task has no start or due date in ClickUp, so it is a one-day bar on 03-Nov-2026, tagged undated.', '5 subtasks keep their parent task, each right after it', '4 tasks have a due date and no start date', '1 task has a start date and no due date, so its bar ends on its start date',
    '1 parent task has no dates of its own', '1 row repeated a task', '1 subtask whose parent is not in the file'].every(s => rp.changed.some(c => c.startsWith(s))), 'the report says what changed on the way in', rp.changed);
  let md = await getT(C, '/changelog?format=md');
  ok(md.includes('REPLACED the whole plan by importing 20 item(s) in 3 phase(s) from clickup-csv. 1 row(s) with no date of their own were placed on 03-Nov-2026 and tagged undated. Not carried over: Priority on 6 tasks;') && md.includes('adom-gantt agent REPLACED'),
    'the changelog\'s import line names the undated rows and what did not come across, as the agent', md.split('\n').filter(l => /REPLACED/.test(l)));
  ok((await getJ(C, '/health')).integrity.ok !== false, 'the chain is whole after the import');
  ok(fs.readdirSync(path.join(C.dir, 'plan-backups')).some(d => d.endsWith('-import')), 'the plan it replaced was backed up first');
  // The plan goes out as CSV and comes back in, a note with a line break included: the plain
  // CSV import used to split the text into lines first and cut such a row in two.
  r = await post(C, '/import/csv', await getT(C, '/export/csv'), Object.assign({ 'Content-Type': 'text/csv' }, auth));
  const back = await getJ(C, '/export/json');
  ok(r.status === 200 && back.items.length === 20 && (byName(back, 'Finalize schematic') || {}).description === w('Finalize schematic').description
    && eq(back.items.map(x => x.uid), ex.items.map(x => x.uid)), 'the imported plan goes out as CSV and back in whole, on the same uids, a two-line note included', [r.status, r.t.slice(0, 200)]);

  // A view export of a Folder (Export view, all columns, ISO dates, US Central).
  r = await post(C, '/import/clickup-csv', read('clickup-view.csv'), auth);
  ex = await getJ(C, '/export/json');
  rp = (r.j && r.j.report) || {};
  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Workcell 11 · Mechanical', 'Workcell 11 · Controls']) && ex.project.title === 'Workcell 11', 'a view export: its Folder and Lists name the sections', [r.status, ex.phases]);
  ok(eq(names(ex, 0), ['Remove the old fixture plate', 'Machine the new fixture plate', 'Order 6061 stock', 'First article inspection', 'Mount the cell frame', 'Retrofit complete'])
    && byName(ex, 'Order 6061 stock').parent === byName(ex, 'Machine the new fixture plate').uid, 'Parent ID places the subtasks and names their parent', names(ex, 0));
  const v = n => byName(ex, n) || {};
  ok(v('Remove the old fixture plate').effort === 6 && v('Remove the old fixture plate').start === '2026-10-05' && v('Remove the old fixture plate').description.split('\n').length === 2, 'hours from "Time Estimate (hours)", ISO days as written, a two-line description', v('Remove the old fixture plate'));
  ok(v('Retrofit complete').type === 'milestone' && v('Retrofit complete').date === '2026-10-30', 'a milestone due at 5 pm keeps its day');
  ok(v('Safety sign-off').status === 'complete' && (rp.statuses || []).some(s => s.name === 'with inspector' && s.by === 'date'), 'an unknown status on a task with a done date is complete, and says why');
  ok(v('First article inspection').status === 'in-progress' && !('owner' in v('PLC program update')) && v('Machine the new fixture plate').description === 'Also assigned in ClickUp: Sara Lind.', 'a guess, an unassigned task, a second assignee');
  ok(drop(rp, 'Custom field Story Points (number)').count === 2 && drop(rp, 'Custom field Vendor (drop down)').count === 1 && drop(rp, 'Custom field Risk Notes (text)').count === 1, 'each custom field is named, with the tasks that had a value', rp.dropped);
  ok(drop(rp, 'Priority').detail === 'urgent 1, high 2, normal 1, low 1' && ['Linked tasks', 'Time tracked', 'Sprint points', 'Comments', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'priority (any case), linked tasks, time logged, points, comments, other Lists', rp.dropped);
  const here = (d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)(new Date());
  ok(rp.undated === 1 && v('Operator training').start === here && eq(v('Operator training').tags, ['undated']), 'with no ?today the undated task lands on this machine\'s date', [rp.undated, v('Operator training'), here]);

  // A view export in ClickUp's Normal date format, with no Parent ID column: the parent's
  // own "Subtask ID's" places its subtasks.
  r = await post(C, '/import/clickup-csv', '"Task ID","Task Name","Status","Due Date","Start Date","Subtask ID\'s","List"\n'
    + '"a1","Parent","to do","Friday, March 10th 2023","Monday, March 6th 2023","[a2]","Sprint 4"\n"a3","Other","in progress","Friday, March 17th 2023","","[]","Sprint 4"\n'
    + '"a2","Child","to do","Wednesday, March 8th 2023","","[]","Sprint 4"\n', auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && eq(names(ex), ['Parent', 'Child', 'Other']) && byName(ex, 'Child').parent === byName(ex, 'Parent').uid && byName(ex, 'Parent').start === '2023-03-06' && byName(ex, 'Parent').end === '2023-03-10' && byName(ex, 'Child').end === '2023-03-08' && ex.phases[0].name === 'Sprint 4',
    'Normal dates are read as written, and Subtask ID\'s places a subtask when Parent ID is not exported', [r.status, ex.items]);
  // POSIX dates with no zone anywhere: the zone that puts the commonest time of day at 04:00.
  const syd = (d) => String(Date.UTC(2026, 10, d, 4, 0) - 11 * 3600000);
  r = await post(C, '/import/clickup-csv', `Task ID,Task Name,Status,Due Date,List\nb1,Due in Sydney,to do,${syd(24)},L\nb2,Also Sydney,to do,${syd(25)},L\n`, auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && byName(ex, 'Due in Sydney').end === '2026-11-24' && byName(ex, 'Also Sydney').end === '2026-11-25', 'epoch dates with no zone named read as the days they were set for (UTC would be a day early)', ex.items);
  // Dependency columns, should an export carry them: "Waiting On" and "Blocking", ids or links.
  r = await post(C, '/import/clickup-csv', 'Task ID,Task Name,Status,Start Date,Due Date,Waiting On,Blocking,List,Budget,Risk (text)\n'
    + 'c1,First,to do,2026-10-05,2026-10-06,,[c3],L,,\nc2,Second,to do,2026-10-07,2026-10-08,"First: https://app.clickup.com/t/c1)",,L,1200,\n'
    + 'c3,Third,to do,2026-10-09,2026-10-10,[c2],,L,,high\nc4,Fourth,to do,2026-10-11,2026-10-12,[zz9],,L,,\n', auth);
  ex = await getJ(C, '/export/json');
  rp = (r.j && r.j.report) || {};
  ok(r.status === 200 && eq(byName(ex, 'Second').deps, ['cu-c1']) && eq([...(byName(ex, 'Third').deps || [])].sort(), ['cu-c1', 'cu-c2']) && !byName(ex, 'Fourth').deps && rp.deps === 3,
    'Waiting On and Blocking become deps, a link read for its task id', ex.items.map(x => [x.name, x.deps]));
  ok(drop(rp, 'Dependencies on tasks that did not come across').count === 1 && !drop(rp, 'Dependencies').what && drop(rp, 'Columns this importer does not read').detail === 'Budget' && drop(rp, 'Custom field Risk (text)').count === 1,
    'a link to a task not in the file is reported; an unknown column and a custom field are named', rp.dropped);

  // ?space= ?folder= ?list= keep part of a workspace export.
  r = await post(C, '/import/clickup-csv?list=firmware', read('clickup-workspace.csv'), auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Sensor Board · Firmware']) && ex.items.length === 3 && ex.project.title === 'Firmware', '?list= keeps one List (any case)', [r.status, ex.phases]);
  r = await post(C, '/import/clickup-csv?folder=Sensor%20Board', read('clickup-workspace.csv'), auth);
  ok(r.status === 200 && r.j.phases === 2, '?folder= keeps a Folder\'s Lists', r.j);
  r = await post(C, '/import/clickup-csv?space=Nope', read('clickup-workspace.csv'), auth);
  ok(r.status === 400 && /No task in this file is in/.test(r.j.error) && /Hardware \/ Launch/.test(r.j.error), 'a filter that matches nothing is refused, naming the file\'s Lists', r.j);

  // Refusals: each names the problem, and the plan stays as it was.
  const kept = await getT(C, '/export/json');
  r = await post(C, '/import/clickup-csv', 'name,start,end\nA,2026-10-05,2026-10-09\n', auth);
  ok(r.status === 400 && /does not look like a ClickUp export/.test(r.j.error), 'a file that is not a ClickUp export is refused, saying what is expected', r.j);
  r = await post(C, '/import/clickup-csv', 'Task ID,Task Name,Status\n', auth);
  ok(r.status === 400 && /no tasks under its header/.test(r.j.error), 'a header with no rows is refused');
  r = await post(C, '/import/csv', read('clickup-workspace.csv'), auth);
  ok(r.status === 400 && /This is a ClickUp export/.test(r.j.error), 'a ClickUp export sent to the plain CSV import says which import to use', r.j);
  r = await post(C, '/import/clickup-json', '{not json', auth);
  const r2 = await post(C, '/import/clickup-json', { tasks: [] }, auth);
  ok(r.status === 400 && /not JSON/.test(r.j.error) && r2.status === 400 && /No ClickUp tasks/.test(r2.j.error), 'the JSON door refuses what is not the API\'s answer', [r.j, r2.j]);
  r = await post(C, '/import/clickup-csv?today=2026-13-40', read('clickup-workspace.csv'), auth);
  ok(r.status === 400 && /today must be a date like/.test(r.j.error), 'a ?today that is not a date is refused', r.j);
  ok((await getT(C, '/export/json')) === kept, 'and none of the refusals touched the plan');
  // A project kept with no dates at all comes in too (Drew, 2026-11-03), every row on the day.
  r = await post(C, '/import/clickup-csv?today=' + DAY, 'Task ID,Task Name,Status,List,Tags\nd1,No dates,to do,L,[ops]\nd2,Nor here,to do,L,\n', auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && r.j.report.undated === 2 && ex.items.every(x => x.start === DAY && x.end === DAY) && eq(byName(ex, 'No dates').tags, ['undated', 'ops']) && eq(byName(ex, 'Nor here').tags, ['undated']),
    'a file with no dated task imports, each row a one-day bar on the day, tagged undated first and keeping its own tags', [r.status, ex.items]);

  // The API's answer, saved: ClickUp's order, dependencies, statuses by type, dates.
  r = await post(C, '/import/clickup-json?today=' + DAY, read('clickup-tasks.json'), auth);
  ex = await getJ(C, '/export/json');
  rp = (r.j && r.j.report) || {};
  const jsonPlan = shape(ex);
  ok(r.status === 200 && eq(ex.phases.map(p => p.name), ['Pick and Place · Line 2 bring-up', 'Pick and Place · Line 2 maintenance']) && /the ClickUp API \(saved JSON\)/.test(ex.project.subtitle), '/import/clickup-json takes the API\'s answer for a List', [r.status, ex.phases]);
  ok(eq(names(ex, 0), ['Level and anchor the machine', 'Install the feeder bank', 'Load the first program', 'Teach fiducials', 'Import the CAD centroid file',
    'First panel off the line', 'Run the 50-panel qualification', 'Supplier visit from Sydney', 'Write the line handbook', 'Second-shift staffing plan']), 'rows in orderindex order, subtasks in theirs, whatever order the file lists them', names(ex, 0));
  ok(byName(ex, 'Teach fiducials').parent === byName(ex, 'Load the first program').uid && byName(ex, 'Import the CAD centroid file').parent === byName(ex, 'Load the first program').uid, 'the API\'s parent becomes the row\'s parent');
  const a = n => byName(ex, n) || {};
  ok(eq(a('Install the feeder bank').deps, ['cu-86c3a0002']) && eq(a('Load the first program').deps, ['cu-86c3a0001']) && eq(a('First panel off the line').deps, ['cu-86c3a0003'])
    && eq(a('Run the 50-panel qualification').deps, ['cu-86c3a0006']) && a('First panel off the line').id === 'cu-86c3a0006' && rp.deps === 4,
    'dependencies, read from either end, become deps: a milestone may wait on a task and a task on a milestone', ex.items.map(x => [x.name, x.id, x.deps]));
  ok(drop(rp, 'Dependencies on tasks that did not come across').count === 1 && !drop(rp, 'Dependencies').what && !rp.dropped.some(d => /milestone/i.test(d.what)), 'only the wait on a task outside the file is counted as not carried', rp.dropped);
  ok(eq(['Level and anchor the machine', 'Install the feeder bank', 'Load the first program', 'Teach fiducials', 'Import the CAD centroid file', 'Run the 50-panel qualification', 'Second-shift staffing plan'].map(n => a(n).status),
    ['complete', 'complete', 'in-progress', 'in-progress', 'not-started', 'blocked', 'cancelled']), 'statuses by ClickUp\'s type: closed, done, custom, open; a closed one named cancelled is cancelled');
  ok(a('Supplier visit from Sydney').start === '2026-11-23' && a('Supplier visit from Sydney').end === '2026-11-24', 'a date with no time set in Sydney keeps its Sydney day', a('Supplier visit from Sydney'));
  ok(a('Run the 50-panel qualification').end === '2026-11-20' && a('Level and anchor the machine').start === '2026-10-28', 'a due date at 3 pm Central keeps its day; a date set under summer time keeps its day', [a('Run the 50-panel qualification'), a('Level and anchor the machine')]);
  ok(a('Load the first program').effort === 16 && a('Import the CAD centroid file').owner === '[email protected]' && /Also assigned in ClickUp: omar\.s@example\.com\./.test(a('Load the first program').description), 'time_estimate in hours; an assignee with no name is their email');
  ok(drop(rp, 'Custom field Machine (drop down)').count === 1 && drop(rp, 'Custom field Budget (currency)').count === 1 && !drop(rp, 'Custom field Notes (text)').what, 'custom fields with a value are named; one with none is not', rp.dropped);
  ok(drop(rp, 'Custom task types').count === 1 && ['Checklists', 'Linked tasks', 'Sprint points', 'Team assignees', 'Tasks also in other Lists'].every(x => drop(rp, x).count === 1), 'custom task types, checklists, links, points, team assignees and other Lists are counted', rp.dropped);
  ok(rp.undated === 1 && a('Write the line handbook').start === DAY && eq(a('Write the line handbook').tags, ['undated']), 'the undated task is a one-day bar on the day, tagged undated');
  const pages = JSON.parse(read('clickup-tasks.json')).tasks;
  r = await post(C, '/import/clickup-json?today=' + DAY, [{ tasks: pages.slice(0, 5), last_page: false }, { tasks: pages.slice(5), last_page: true }], auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && shape(ex) === jsonPlan, 'several pages in an array make the same plan as one');
  const at = (m, d) => String(Date.UTC(2026, m - 1, d, 9, 0));
  r = await post(C, '/import/clickup-json', { tasks: [
    { id: 'e1', name: 'Approved, not built', status: { status: 'approved', type: 'custom' }, start_date: at(10, 5), due_date: at(10, 6), list: { id: '1', name: 'L' } },
    { id: 'e2', name: 'Out the door', status: { status: 'shipped', type: 'done' }, due_date: at(10, 7), list: { id: '1', name: 'L' } },
    { id: 'e3', name: 'Next up', status: { status: 'ready for dev', type: 'custom' }, due_date: at(10, 8), list: { id: '1', name: 'L' } } ] }, auth);
  ex = await getJ(C, '/export/json');
  ok(r.status === 200 && eq(names(ex).map(nm => byName(ex, nm).status), ['in-progress', 'complete', 'not-started']),
    'a middle status that sounds finished follows ClickUp\'s type and is in progress; a done one named anything is complete', ex.items);
  // The subtasks model's limits: a milestone is never a parent, and three levels is the deepest.
  const L = { id: '1', name: 'L' }, open = { status: 'to do', type: 'open' };
  const T = (id, name, parent, extra) => Object.assign({ id, name, status: open, start_date: at(10, 5), due_date: at(10, 9), list: L }, parent ? { parent } : {}, extra || {});
  r = await post(C, '/import/clickup-json', { tasks: [T('g0', 'Top'), T('g1', 'Level 1', 'g0'), T('g2', 'Level 2', 'g1'), T('g3', 'Level 3', 'g2'), T('g4', 'Level 4', 'g3'),
    T('m0', 'Gate', null, { custom_item_id: 1, start_date: null }), T('m1', 'Under the gate', 'm0'),
    T('m2', 'Gate under a task', 'g0', { custom_item_id: 1, start_date: null }), T('m3', 'Under the inner gate', 'm2')] }, auth);
  ex = await getJ(C, '/export/json');
  rp = (r.j && r.j.report) || {};
  const up = nm => { const x = byName(ex, nm) || {}; return x.parent ? (ex.items.find(y => y.uid === x.parent) || {}).name : null; };
  ok(r.status === 200 && eq(['Level 1', 'Level 2', 'Level 3', 'Level 4', 'Gate under a task', 'Under the inner gate', 'Under the gate', 'Gate', 'Top'].map(up),
    ['Top', 'Level 1', 'Level 2', 'Level 2', 'Top', 'Top', null, null, null]),
    'a fourth level sits at the third; a milestone may be a subtask but never a parent: its subtask goes to the nearest task above, or the top', ex.items.map(x => [x.name, up(x.name)]));
  ok(rp.subtasks === 6 && ['1 subtask is nested more than three levels deep', '1 subtask of a milestone sits under the nearest task above it', '1 subtask of a milestone is a top-level row'].every(t => rp.changed.some(c => c.startsWith(t))),
    'the report says which subtasks moved, and why', rp.changed);
  // Undated rows in a tree (Drew, 2026-11-03): a parent with no dates spans its dated subtasks;
  // a subtask with none is a one-day bar on the day, tagged, and so is a parent whose subtasks
  // have none either, and a milestone with none. The tag survives a full list of ClickUp tags.
  const U = (id, name, parent, extra) => Object.assign({ id, name, status: open, list: L }, parent ? { parent } : {}, extra || {});
  const twelve = Array.from({ length: 12 }, (_, i) => ({ name: 'k' + i }));
  r = await post(C, '/import/clickup-json?today=' + DAY, { tasks: [
    U('u0', 'Spans its dated child'), U('u1', 'Dated child', 'u0', { start_date: at(10, 12), due_date: at(10, 14) }), U('u2', 'Undated child', 'u0'),
    U('u3', 'Undated parent'), U('u4', 'Undated grandchild', 'u3'), U('u5', 'Undated gate', null, { custom_item_id: 1 }), U('u6', 'Many tags', null, { tags: twelve })] }, auth);
  ex = await getJ(C, '/export/json');
  const x = nm => byName(ex, nm) || {};
  ok(r.status === 200 && x('Spans its dated child').start === '2026-10-12' && x('Spans its dated child').end === '2026-10-14' && !x('Spans its dated child').tags
    && x('Undated child').start === DAY && eq(x('Undated child').tags, ['undated']) && x('Undated parent').start === DAY && eq(x('Undated parent').tags, ['undated'])
    && x('Undated grandchild').start === DAY && x('Undated grandchild').parent === x('Undated parent').uid && x('Undated gate').date === DAY && eq(x('Undated gate').tags, ['undated'])
    && x('Many tags').tags.length === 12 && x('Many tags').tags[0] === 'undated' && r.j.report.undated === 5,
    'a parent spans its dated subtasks only; undated subtasks, parents and milestones land on the day, tagged; the tag survives the tag cap', ex.items.map(y => [y.name, y.start || y.date, y.end, y.tags]));
  stop(C);

  // The live API, against a stand-in for ClickUp on loopback.
  const http = await import('node:http');
  const TOKEN = 'pk_81000001_FAKETOKENFORTHESUITE0123456789AB';
  const seen = [];
  const L1 = pages.filter(t => t.list.id === '901300000501'), L2 = pages.filter(t => t.list.id === '901300000502');
  const fake = http.createServer((req, res) => {
    seen.push({ url: req.url, auth: req.headers.authorization });
    const send = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
    if (req.headers.authorization !== TOKEN) return send(401, { err: 'Token invalid', ECODE: 'OAUTH_025' });
    const u = new URL(req.url, 'http://x');
    if (u.pathname === '/api/v2/user') return send(200, { user: { id: 81000001, username: 'Nadia Haddad', timezone: 'America/Chicago' } });
    if (u.pathname === '/api/v2/folder/90130000100/list') return send(200, { lists: [{ id: '901300000501', name: 'Line 2 bring-up' }, { id: '901300000502', name: 'Line 2 maintenance' }] });
    const m = u.pathname.match(/^\/api\/v2\/list\/(\d+)\/task$/);
    if (m && u.searchParams.get('subtasks') === 'true' && u.searchParams.get('include_closed') === 'true') {
      const page = +u.searchParams.get('page');
      if (m[1] === '901300000501') return send(200, page === 0 ? { tasks: L1.slice(0, 6), last_page: false } : { tasks: L1.slice(6), last_page: true });
      if (m[1] === '901300000502') return send(200, { tasks: L2, last_page: true });
    }
    return send(404, { err: 'Route not found' });
  });
  await new Promise(res => fake.listen(0, '127.0.0.1', res));
  try {
    const tokFile = path.join(TMP, 'clickup-token');
    fs.writeFileSync(tokFile, TOKEN + '\n', { mode: 0o600 });
    fs.chmodSync(tokFile, 0o600);
    const K = await start('clickup-api', basePlan(false), null, 8940,
      { CLICKUP_API_BASE: `http://127.0.0.1:${fake.address().port}/api/v2`, CLICKUP_TOKEN: '', CLICKUP_TOKEN_FILE: '' }, { args: ['--clickup-token-file', tokFile] });
    const kauth = { Authorization: 'Bearer ' + K.token };
    let g = await (await fetch(K.base + '/import/clickup-api', { headers: kauth })).json();
    ok(g.configured === true && g.from === 'file' && !JSON.stringify(g).includes(TOKEN), 'GET /import/clickup-api says a token is set up, from the file, and never shows it', g);
    r = await post(K, '/import/clickup-api?today=2026-11-03', { folder: '90130000100' }, kauth);
    ex = await getJ(K, '/export/json');
    ok(r.status === 200 && shape(ex) === jsonPlan && /the ClickUp API$/.test(ex.project.subtitle), 'a Folder from the live API makes the same plan as the saved JSON', [r.status, r.t.slice(0, 200)]);
    ok(seen.some(s => /\/list\/901300000501\/task\?.*page=1/.test(s.url)) && seen.every(s => s.auth === TOKEN), 'it follows the pages, and sends the token in ClickUp\'s header', seen.map(s => s.url));
    const files = fs.readdirSync(K.dir).filter(f => fs.statSync(path.join(K.dir, f)).isFile()).map(f => fs.readFileSync(path.join(K.dir, f), 'utf8')).join('\n');
    ok(!files.includes(TOKEN) && !r.t.includes(TOKEN) && !(await getT(K, '/changelog')).includes(TOKEN), 'the token is in no file beside the plan, no answer and no log line');
    r = await post(K, '/import/clickup-api', { url: 'https://app.clickup.com/9013000001/v/li/901300000502' }, kauth);
    ex = await getJ(K, '/export/json');
    ok(r.status === 200 && eq(names(ex), ['Weekly nozzle cleaning']), 'a List\'s link imports that List', [r.status, names(ex)]);
    r = await post(K, '/import/clickup-api', { list: '../../user' }, kauth);
    ok(r.status === 400 && /is not a ClickUp id/.test(r.j.error), 'an id that is not one is refused before anything is asked of ClickUp', r.j);
    r = await post(K, '/import/clickup-api', {}, kauth);
    ok(r.status === 400 && /Say what to import/.test(r.j.error), 'a request that names nothing is refused, saying how');
    fs.chmodSync(tokFile, 0o644);
    g = await (await fetch(K.base + '/import/clickup-api', { headers: kauth })).json();
    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);
    ok(g.configured === false && /chmod 600/.test(g.error) && r.status === 400 && /chmod 600/.test(r.j.error), 'a token file other users can read is not used', [g, r.j]);
    fs.chmodSync(tokFile, 0o600);
    fs.writeFileSync(tokFile, 'pk_wrong\n');
    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);
    ok(r.status === 502 && /ClickUp refused the token \(401\)/.test(r.j.error), 'a token ClickUp refuses is a 502 that says so', r.j);
    fs.rmSync(tokFile);
    r = await post(K, '/import/clickup-api', { list: '901300000502' }, kauth);
    ok(r.status === 400 && /No ClickUp token/.test(r.j.error), 'no token at all is a 400 that says where one goes', r.j);
    ok(eq(names(await getJ(K, '/export/json')), ['Weekly nozzle cleaning']), 'and none of those touched the plan');
    stop(K);
  } finally { fake.close(); }
}

// ── A whole-state save that changes a created row (0.79.0) ──
// The sidecar holds a created row twice: its entry in `added` and, once anybody has edited it, a
// full override in `items` on top. On 2026-11-03 an agent's whole-state save changed a created
// row's dependencies and dates in `added` and sent the overrides back as it had read them, so the
// override (written by an earlier owner edit) hid both changes. A save now carries such a change
// into the override, and only where the override still held the field's previous value.
async function savedCreatedSection() {
  console.log('H. a whole-state save that changes a created row');
  const plan = { project: { title: 'Created rows', subtitle: 'server_test' }, phases: [{ name: 'A', start: '', end: '' }],
    items: [{ type: 'task', uid: '00001', id: 't1', name: 'First', start: '2026-10-05', end: '2026-10-09', phase: 0 }] };
  const created = { type: 'task', uid: '00010', id: 'c1', name: 'Created', start: '2026-10-05', end: '2026-10-09', phase: 0, owner: 'Drew', deps: [] };
  // The full override an owner edit writes: every field, with the owner changed to Ana on purpose.
  const ov = { uid: '00010', owner: 'Ana', status: '', desc: '', prov: '', name: 'Created', phase: 0, deps: [], s: '2026-10-05', e: '2026-10-09', note: '', effort: null };
  const side = { v: 4, items: [ov], deleted: [], added: [created], order: ['00001', '00010'], stateRev: 5, phases: [{ i: 0, name: 'A', start: '', end: '', tags: [] }] };
  const S = await start('save-created', plan, side, 8941);
  const auth = { Authorization: 'Bearer ' + S.token };
  const row = async () => ((await (await fetch(S.base + '/export/json', { headers: auth })).json()).items || []).find(r => r.uid === '00010') || {};
  const stored = () => JSON.parse(fs.readFileSync(path.join(S.dir, 'plan-state.json'), 'utf8'));
  ok((await row()).owner === 'Ana' && eq((await row()).deps || [], []), 'before: the override stands over the created row (owner Ana, no dependencies)', await row());
  const bak = path.join(S.dir, 'plan-state.json.pre-pos.bak');
  ok(fs.existsSync(bak) && eq(JSON.parse(fs.readFileSync(bak, 'utf8')).items, side.items) && stored().items[0].pos, 'the first start gives every row its place key and keeps the sidecar as it was in .pre-pos.bak', fs.existsSync(bak));
  // What the agent did: read the state, change the created row in `added`, send everything back.
  let st = await getJ(S, '/state');
  let a = st.added.find(x => x.uid === '00010');
  a.deps = ['t1']; a.end = '2026-10-16'; a.owner = 'Bo';
  let r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);
  let x = await row();
  ok(r.status === 200 && eq(x.deps, ['t1']) && x.end === '2026-10-16', 'a whole-state save that gives a created row a dependency and a new end keeps both, though the override still held the old values', x);
  ok(x.owner === 'Ana', 'an override that says something else on purpose (the owner) still stands', x.owner);
  const o = stored().items.find(y => y.uid === '00010');
  ok(eq(o.deps, ['t1']) && o.e === '2026-10-16' && o.owner === 'Ana', 'the override took the new dependency and end, and kept its own owner', o);
  // A save that changes the override itself keeps the override's own value.
  st = await getJ(S, '/state');
  st.items.find(y => y.uid === '00010').e = '2026-10-20';
  st.added.find(y => y.uid === '00010').end = '2026-10-23';
  r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);
  ok(r.status === 200 && (await row()).end === '2026-10-20', 'a save that changes the override itself keeps the override\'s value', (await row()).end);
  // Sent back exactly as read, nothing moves.
  const before = JSON.stringify(await row());
  st = await getJ(S, '/state');
  r = await post(S, '/save', Object.assign({}, st, { baseRev: st.stateRev }), auth);
  ok(r.status === 200 && JSON.stringify(await row()) === before, 'a save sent back exactly as read changes nothing');
  stop(S);
}

// A section runs when no --only is given or its name contains one of the --only terms.
const SECTIONS = [['core', main], ['clickup import', clickupSection], ['save created rows', savedCreatedSection]];
try { for (const [name, fn] of SECTIONS) if (!ONLY || ONLY.some(o => name.includes(o))) await fn(); }
catch (e) { fail++; console.log('  FAIL threw: ' + (e && e.stack || e)); }
finally {
  procs.forEach(p => { try { p.kill('SIGTERM'); } catch (e) {} });
  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);