create-pmos 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +50 -13
  5. package/workspace/bin/pmos.js +204 -51
  6. package/workspace/design-parity.js +358 -0
  7. package/workspace/doctor.js +54 -0
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/four-panel.js +345 -0
  10. package/workspace/lib/git-info.js +28 -1
  11. package/workspace/lib/mobile-nav.js +32 -2
  12. package/workspace/lib/server-discovery.js +127 -0
  13. package/workspace/package-lock.json +29 -2
  14. package/workspace/package.json +13 -5
  15. package/workspace/public/index.html +79 -409
  16. package/workspace/public/pm/app.js +242 -0
  17. package/workspace/public/pm/data.js +905 -0
  18. package/workspace/public/pm/dom.js +116 -0
  19. package/workspace/public/pm/fixture.js +979 -0
  20. package/workspace/public/pm/mobile.css +200 -0
  21. package/workspace/public/pm/mobile.js +403 -0
  22. package/workspace/public/pm/modes.js +1076 -0
  23. package/workspace/public/pm/panel-detail.js +648 -0
  24. package/workspace/public/pm/panel-initiatives.js +426 -0
  25. package/workspace/public/pm/panel-repos.js +553 -0
  26. package/workspace/public/pm/panel-tabs.js +487 -0
  27. package/workspace/public/pm/state.js +168 -0
  28. package/workspace/public/pm/tab-doc.js +279 -0
  29. package/workspace/public/pm/tab-task.js +83 -0
  30. package/workspace/public/pm/tab-term.js +560 -0
  31. package/workspace/public/pm/tokens.css +73 -0
  32. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  33. package/workspace/server.js +174 -11
  34. package/workspace/focus.test.js +0 -650
  35. package/workspace/handoff.test.js +0 -443
  36. package/workspace/profiles.test.js +0 -362
  37. package/workspace/public/app.js +0 -5560
  38. package/workspace/public/styles.css +0 -2258
  39. package/workspace/repo-scan.test.js +0 -327
@@ -83,6 +83,7 @@ const repoScan = require('./lib/repo-scan'); // scanRepo(root) — pur
83
83
  const profilesLib = require('./lib/profiles'); // readProfiles(homeDir) / profileLine(profile, read)
84
84
  const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
85
85
  const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
86
+ const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
86
87
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
87
88
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
88
89
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -106,6 +107,7 @@ const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
106
107
  // (unsupported direct run) has no home — the client must open a repo to do anything.
107
108
  const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
108
109
  const PUB = path.join(__dirname, 'public');
110
+ const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
109
111
  const XROOT = path.join(__dirname, 'node_modules', '@xterm');
110
112
  const STATE = path.join(__dirname, 'state');
111
113
  const SESS_DIR = path.join(STATE, 'sessions');
@@ -119,7 +121,11 @@ const CLONE_DIR = path.join(STATE, 'clones');
119
121
  // PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
120
122
  // workspace preference, not an agent attribute — the file lives here for exactly that reason.
121
123
  const PREFS_FILE = path.join(STATE, 'prefs.json');
122
- const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
124
+ const discovery = require('./lib/server-discovery'); // how a launcher FINDS a running server
125
+ const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : discovery.DEFAULT_PORT;
126
+ // Written on listen, removed on shutdown. This is the server's answer to "where am I?" — see
127
+ // lib/server-discovery.js for why a launcher must ask rather than assume.
128
+ const PID_FILE = path.join(STATE, discovery.pidFileName(PORT));
123
129
  const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
124
130
  const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
125
131
  // Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
@@ -306,10 +312,15 @@ function repoIdentity(root) {
306
312
  const now = Date.now();
307
313
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
308
314
  let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
315
+ let remote = null;
309
316
  try {
310
317
  const trees = gitInfo.worktrees(root);
311
318
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
312
319
  } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
320
+ // Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
321
+ // another `git` shell-out on a call that runs for every workspace on every push. A failure is not
322
+ // a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
323
+ try { remote = gitInfo.originUrl(root); } catch { remote = null; }
313
324
  try {
314
325
  const bi = gitInfo.branchInfo(root);
315
326
  if (bi.ok) branch = bi.detached ? null : bi.current;
@@ -322,7 +333,7 @@ function repoIdentity(root) {
322
333
  // one drifts.
323
334
  else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
324
335
  } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
325
- const val = { at: now, key, name, branch, branchError, branchCode };
336
+ const val = { at: now, key, name, branch, branchError, branchCode, remote };
326
337
  repoKeyCache.set(root, val);
327
338
  return val;
328
339
  }
@@ -338,6 +349,14 @@ function wsList() {
338
349
  // the client renders the first quietly and the second as a fault (R1/R2).
339
350
  repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
340
351
  branch_error: rid.branchError, branch_code: rid.branchCode,
352
+ // Where the tree came from, and the ONE label the repository list can honestly put on a row.
353
+ // `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
354
+ // answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
355
+ // cloned from GitHub, or a folder on this machine. A remote that is not GitHub's reads as
356
+ // `local`, because `github` would be a claim about a host nobody contacted. Deriving `github`
357
+ // from the mere presence of a `.git` directory is what this replaces.
358
+ remote: rid.remote,
359
+ kind: gitInfo.isGithubRemote(rid.remote) ? 'github' : 'local',
341
360
  };
342
361
  });
343
362
  }
@@ -745,9 +764,34 @@ function surfaceRoadmap(root, w) {
745
764
  // (KR\d+) — so `## O-INTEGRITY` and `KR-C` were invisible here while the ladder happily accepted
746
765
  // anchors naming them. One field with two opinions is the defect this initiative removes; a second
747
766
  // parser would have been the same bug wearing a different regex.
767
+ /* WHERE THE RESOLVER ACTUALLY LIVES — the same two-location lookup `lib/console-state.js` already
768
+ * does for the workflow engine, and for the same reason (incident kit-workflow-engine-missing).
769
+ * This function looked ONLY at `../scripts/anchor-resolve.js`, which exists in a PMOS checkout and
770
+ * nowhere else: the published package ships bin/ payload/ workspace/, so in an adopter's install
771
+ * there is no `scripts/` beside the app and every OKR and spine read answered "the anchor resolver
772
+ * is not present in this install". Panel 1 showed no count and panel 2 no groups, in every release
773
+ * that had the surface.
774
+ *
775
+ * It was invisible to every existing check because a dev checkout always has the file one directory
776
+ * up — the identical blind spot that hid the engine. It was found by launching the INSTALLED app
777
+ * with egress blocked (.github/scripts/offline-workspace-smoke.js), which is the shape adopters get
778
+ * and the shape nothing used to run.
779
+ *
780
+ * 1. `../scripts/anchor-resolve.js` — a repo checkout, preferred so a dev panel and CI cannot
781
+ * disagree about what resolves.
782
+ * 2. `lib/engine/anchor-resolve.js` — the copy `scripts/vendor-workspace-engine.js` vendors into
783
+ * the bundle (it is in the engine's own require closure, so it is already shipped). This is
784
+ * what an adopter runs. */
785
+ const ANCHOR_LIB_PATHS = [
786
+ path.join(__dirname, '..', 'scripts', 'anchor-resolve.js'),
787
+ path.join(__dirname, 'lib', 'engine', 'anchor-resolve.js'),
788
+ ];
748
789
  function anchorLib() {
749
- try { return require(path.join(__dirname, '..', 'scripts', 'anchor-resolve.js')); }
750
- catch { return null; }
790
+ for (const p of ANCHOR_LIB_PATHS) {
791
+ if (!fs.existsSync(p)) continue;
792
+ try { return require(p); } catch { return null; }
793
+ }
794
+ return null;
751
795
  }
752
796
  function surfaceOkr(root) {
753
797
  const A = anchorLib();
@@ -1045,7 +1089,7 @@ function unpersist(id) {
1045
1089
  }
1046
1090
  }
1047
1091
  // Scrollback written by the PRE-control-mode architecture carries the tmux CLIENT's own screen
1048
- // setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/app.js) — putting
1092
+ // setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/pm/tab-term.js) — putting
1049
1093
  // the terminal straight back into the alternate buffer, where there is no scrollback. Strip it.
1050
1094
  //
1051
1095
  // The two signatures below are the client's, not an application's: the alt-screen switch IMMEDIATELY
@@ -2657,7 +2701,7 @@ function serveFile(res, file, type, noCache) {
2657
2701
  fs.readFile(file, (err, buf) => {
2658
2702
  if (err) { res.writeHead(404); res.end('not found'); return; }
2659
2703
  const headers = { 'Content-Type': type || TYPES[path.extname(file)] || 'application/octet-stream' };
2660
- // The app SHELL (index.html + app.js) must revalidate every load: without this a tab left open
2704
+ // The app SHELL (index.html + the /pm/ module graph) must revalidate every load: without this a tab left open
2661
2705
  // across a `git merge` + server restart keeps running the pre-merge JS, so a fix that IS being
2662
2706
  // served never reaches the screen. `no-cache` = "always revalidate", not "never store".
2663
2707
  if (noCache) headers['Cache-Control'] = 'no-cache';
@@ -2678,7 +2722,10 @@ async function handleRequest(req, res) {
2678
2722
  const p = url.pathname;
2679
2723
 
2680
2724
  if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
2681
- if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
2725
+ // NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
2726
+ // public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
2727
+ // /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
2728
+ // 404s loudly rather than silently resurrecting a second, diverging surface.
2682
2729
  if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
2683
2730
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
2684
2731
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
@@ -2689,7 +2736,8 @@ async function handleRequest(req, res) {
2689
2736
  // client feature that depends on it fails in a way no server-side test can see.
2690
2737
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
2691
2738
  if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
2692
- if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
2739
+ // …and no `/styles.css` route either: the token layer and every component rule now ship as
2740
+ // /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
2693
2741
  // Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
2694
2742
  // puts a permanent red line in the console and in this suite's `page errors seen` list — noise
2695
2743
  // that trains you to ignore the one place a real client-side failure would show up. An inline SVG
@@ -2711,7 +2759,28 @@ async function handleRequest(req, res) {
2711
2759
  // because the terminal renders bold — with only the regular face the browser synthesises it.
2712
2760
  if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
2713
2761
  if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
2762
+ // 500 joined 400/700 with the four-panel design, whose section labels and pills are medium-weight
2763
+ // mono. Without the face the browser synthesises it, and a synthesised weight is exactly the kind
2764
+ // of difference the design-parity comparator reports and a human eye does not.
2765
+ if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
2714
2766
  if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
2767
+ // ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
2768
+ // A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
2769
+ // records why it is a trap: a file missing from it 404s silently and the feature that needed it
2770
+ // fails somewhere no server-side test looks. That cost is tolerable for six stable modules and is
2771
+ // not tolerable for a front end split into fifteen, where every new module would be one forgotten
2772
+ // line away from a blank panel. The traversal guard is what makes the directory form safe: the
2773
+ // resolved path must still sit inside PM_DIR, so '..' segments and absolute-looking names cannot
2774
+ // reach a file outside it. Extension-restricted as well — this route serves code and styles, not
2775
+ // whatever else happens to land in the folder.
2776
+ if (p.startsWith('/pm/')) {
2777
+ const rel = p.slice('/pm/'.length);
2778
+ const file = path.resolve(PM_DIR, rel);
2779
+ if (!file.startsWith(PM_DIR + path.sep) || !['.js', '.css', '.json'].includes(path.extname(file))) {
2780
+ res.writeHead(404); return res.end('not found');
2781
+ }
2782
+ return serveFile(res, file, null, true);
2783
+ }
2715
2784
 
2716
2785
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
2717
2786
  if (p === '/api/workspaces' && req.method === 'POST') {
@@ -3260,6 +3329,87 @@ async function handleRequest(req, res) {
3260
3329
  if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3261
3330
  return json(res, { name, ...out }, code);
3262
3331
  }
3332
+ // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3333
+ // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
3334
+ // its sessions to answer a single question about a single initiative. Every one of those is
3335
+ // already produced somewhere here; what did not exist was the JOIN, so each client had to make
3336
+ // it — four round-trips per selection, and four chances for two surfaces to disagree about one
3337
+ // record. This route calls the SAME producers the existing endpoints call and joins them once.
3338
+ // The existing endpoints are deliberately unchanged: other surfaces still read them.
3339
+ //
3340
+ // GET → the assembled payload.
3341
+ // POST → append one declaration to a file-mode store (see fourPanel's header for why a link is
3342
+ // a declaration and never an outbound call).
3343
+ if (p === '/api/initiative' && req.method === 'GET') {
3344
+ const w = getWs(url.searchParams.get('ws'));
3345
+ const root = w ? w.root : SELF_ROOT;
3346
+ const id = url.searchParams.get('id') || '';
3347
+ // An id is a filename, not a path — the same guard the handoff surface applies, and the reason
3348
+ // this route can join a path without a traversal check at every use site.
3349
+ if (!fourPanel.validId(id)) {
3350
+ return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
3351
+ }
3352
+ const engine = consoleState.loadEngine();
3353
+ if (!engine.ok) return json(res, { ok: false, id, error: engine.error });
3354
+ const L = engine.engine.layout(root);
3355
+ // The ENGINE's frontmatter parser, not the workspace's — see lib/four-panel.js on why the two
3356
+ // disagree and why this endpoint's `frontmatter` has to agree with its own `ladder`.
3357
+ const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
3358
+ // A record that would not READ is the one fact the panel must show, and it is the whole
3359
+ // answer: deriving a ladder over a file that could not be parsed would be deriving over
3360
+ // nothing, and reporting the result beside the failure would invite reading it as data.
3361
+ let ladder = null, linksOut = null;
3362
+ if (rec.ok) {
3363
+ try { ladder = consoleState.ladder({ root, id }); }
3364
+ catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
3365
+ // An ABSENT record declares nothing, so there is nothing for the handoff reader to read —
3366
+ // distinct from a record that declares no handoff, which readLinks reports as empty.
3367
+ if (!rec.absent) {
3368
+ try { linksOut = handoff.readLinks(rec.frontmatter || {}); }
3369
+ catch (e) { linksOut = null; void e; }
3370
+ }
3371
+ }
3372
+ let index = { initiatives: [] };
3373
+ try { if (wsShape(root) === 'pmos') index = initiativeIndex(root); }
3374
+ catch (e) { void e; }
3375
+ const stateDir = fourPanel.storeDir(engine.engine, root);
3376
+ const out = fourPanel.assemble({
3377
+ id, rec, ladder, links: linksOut, index,
3378
+ sessions: sessionList(), workspaces: wsList(),
3379
+ anchor: rec.ok && !rec.absent
3380
+ ? fourPanel.anchorFor({ anchors: anchorLib(), root, raw: (rec.frontmatter || {}).parent_kr })
3381
+ : null,
3382
+ artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3383
+ initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3384
+ });
3385
+ // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3386
+ return json(res, { ws: w ? w.id : null, ...out });
3387
+ }
3388
+ if (p === '/api/initiative/link' && req.method === 'POST') {
3389
+ // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3390
+ // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
3391
+ // and it cannot state anything about the destination's contents.
3392
+ const w = getWs(url.searchParams.get('ws'));
3393
+ let body = {};
3394
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3395
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3396
+ const w2 = body.ws ? getWs(body.ws) : w;
3397
+ const kind = String(body.store || 'artifact');
3398
+ const built = kind === 'initiative'
3399
+ ? fourPanel.initiativeLinkRow(body)
3400
+ : fourPanel.artifactLinkRow(body);
3401
+ if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3402
+ const engine = consoleState.loadEngine();
3403
+ if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
3404
+ const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
3405
+ const wrote = fourPanel.appendDeclaration(
3406
+ stateDir,
3407
+ kind === 'initiative' ? fourPanel.INITIATIVE_LINKS : fourPanel.ARTIFACT_LINKS,
3408
+ built.row,
3409
+ );
3410
+ // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3411
+ return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
3412
+ }
3263
3413
  if (p === '/api/search') {
3264
3414
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
3265
3415
  // else here — an agent running in a pane can curl it to check what an earlier run tried.
@@ -3763,6 +3913,7 @@ if (require.main === module) {
3763
3913
  if (shuttingDown) return; shuttingDown = true;
3764
3914
  for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
3765
3915
  for (const l of listeners) { try { l.close(); } catch {} }
3916
+ try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
3766
3917
  process.exit(0);
3767
3918
  };
3768
3919
  process.on('SIGTERM', shutdown);
@@ -3770,7 +3921,9 @@ if (require.main === module) {
3770
3921
  const onError = (e) => {
3771
3922
  if (e.code === 'EADDRINUSE') {
3772
3923
  console.error(`✗ port ${PORT} is already in use (another workspace server running?).`);
3773
- console.error(` Stop it, or start on another port: PMOS_WS_PORT=7500 npm start`);
3924
+ console.error(` \`pmos\` finds a running workspace on any port and reuses it, and picks a free`);
3925
+ console.error(` port when something else holds this one. \`pmos stop\` shuts running ones down.`);
3926
+ console.error(` To pin a port by hand: PMOS_WS_PORT=7500 npm start`);
3774
3927
  process.exit(1);
3775
3928
  }
3776
3929
  if (e.code === 'EADDRNOTAVAIL') {
@@ -3785,7 +3938,13 @@ if (require.main === module) {
3785
3938
  l.on('error', onError);
3786
3939
  l.listen(PORT, addr, () => {
3787
3940
  // stdout only — no request/header logging (D08). Banner (incl. any generated token) printed once.
3788
- if (++up === BIND.binds.length) for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
3941
+ if (++up !== BIND.binds.length) return;
3942
+ // Record the port WE are serving, whoever started us. The launcher discovers a running
3943
+ // workspace from these files, so a server started by `npm start` (which is exactly what the
3944
+ // old EADDRINUSE advice told the PM to do, on another port) has to leave the same trace as
3945
+ // one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
3946
+ try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
3947
+ for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
3789
3948
  });
3790
3949
  });
3791
3950
  }
@@ -3806,4 +3965,8 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3806
3965
  trimBuf, SCROLLBACK,
3807
3966
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
3808
3967
  // asserted without a server that has no clients (the suite always has both).
3809
- shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };
3968
+ shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
3969
+ // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3970
+ // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3971
+ // exactly as it hid the workflow engine's absence for four releases (PKG4).
3972
+ ANCHOR_LIB_PATHS };