create-pmos 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/find-browser.js +60 -0
  7. package/workspace/lib/four-panel.js +345 -0
  8. package/workspace/lib/git-info.js +28 -1
  9. package/workspace/lib/mobile-nav.js +32 -2
  10. package/workspace/package-lock.json +29 -2
  11. package/workspace/package.json +13 -5
  12. package/workspace/public/index.html +79 -409
  13. package/workspace/public/pm/app.js +242 -0
  14. package/workspace/public/pm/data.js +877 -0
  15. package/workspace/public/pm/dom.js +116 -0
  16. package/workspace/public/pm/fixture.js +979 -0
  17. package/workspace/public/pm/mobile.css +200 -0
  18. package/workspace/public/pm/mobile.js +403 -0
  19. package/workspace/public/pm/modes.js +1076 -0
  20. package/workspace/public/pm/panel-detail.js +648 -0
  21. package/workspace/public/pm/panel-initiatives.js +426 -0
  22. package/workspace/public/pm/panel-repos.js +460 -0
  23. package/workspace/public/pm/panel-tabs.js +487 -0
  24. package/workspace/public/pm/state.js +164 -0
  25. package/workspace/public/pm/tab-doc.js +279 -0
  26. package/workspace/public/pm/tab-task.js +83 -0
  27. package/workspace/public/pm/tab-term.js +560 -0
  28. package/workspace/public/pm/tokens.css +73 -0
  29. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  30. package/workspace/server.js +158 -8
  31. package/workspace/focus.test.js +0 -650
  32. package/workspace/handoff.test.js +0 -443
  33. package/workspace/profiles.test.js +0 -362
  34. package/workspace/public/app.js +0 -5560
  35. package/workspace/public/styles.css +0 -2258
  36. 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');
@@ -306,10 +308,15 @@ function repoIdentity(root) {
306
308
  const now = Date.now();
307
309
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
308
310
  let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
311
+ let remote = null;
309
312
  try {
310
313
  const trees = gitInfo.worktrees(root);
311
314
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
312
315
  } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
316
+ // Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
317
+ // another `git` shell-out on a call that runs for every workspace on every push. A failure is not
318
+ // a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
319
+ try { remote = gitInfo.originUrl(root); } catch { remote = null; }
313
320
  try {
314
321
  const bi = gitInfo.branchInfo(root);
315
322
  if (bi.ok) branch = bi.detached ? null : bi.current;
@@ -322,7 +329,7 @@ function repoIdentity(root) {
322
329
  // one drifts.
323
330
  else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
324
331
  } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
325
- const val = { at: now, key, name, branch, branchError, branchCode };
332
+ const val = { at: now, key, name, branch, branchError, branchCode, remote };
326
333
  repoKeyCache.set(root, val);
327
334
  return val;
328
335
  }
@@ -338,6 +345,14 @@ function wsList() {
338
345
  // the client renders the first quietly and the second as a fault (R1/R2).
339
346
  repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
340
347
  branch_error: rid.branchError, branch_code: rid.branchCode,
348
+ // Where the tree came from, and the ONE label the repository list can honestly put on a row.
349
+ // `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
350
+ // answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
351
+ // cloned from GitHub, or a folder on this machine. A remote that is not GitHub's reads as
352
+ // `local`, because `github` would be a claim about a host nobody contacted. Deriving `github`
353
+ // from the mere presence of a `.git` directory is what this replaces.
354
+ remote: rid.remote,
355
+ kind: gitInfo.isGithubRemote(rid.remote) ? 'github' : 'local',
341
356
  };
342
357
  });
343
358
  }
@@ -745,9 +760,34 @@ function surfaceRoadmap(root, w) {
745
760
  // (KR\d+) — so `## O-INTEGRITY` and `KR-C` were invisible here while the ladder happily accepted
746
761
  // anchors naming them. One field with two opinions is the defect this initiative removes; a second
747
762
  // parser would have been the same bug wearing a different regex.
763
+ /* WHERE THE RESOLVER ACTUALLY LIVES — the same two-location lookup `lib/console-state.js` already
764
+ * does for the workflow engine, and for the same reason (incident kit-workflow-engine-missing).
765
+ * This function looked ONLY at `../scripts/anchor-resolve.js`, which exists in a PMOS checkout and
766
+ * nowhere else: the published package ships bin/ payload/ workspace/, so in an adopter's install
767
+ * there is no `scripts/` beside the app and every OKR and spine read answered "the anchor resolver
768
+ * is not present in this install". Panel 1 showed no count and panel 2 no groups, in every release
769
+ * that had the surface.
770
+ *
771
+ * It was invisible to every existing check because a dev checkout always has the file one directory
772
+ * up — the identical blind spot that hid the engine. It was found by launching the INSTALLED app
773
+ * with egress blocked (.github/scripts/offline-workspace-smoke.js), which is the shape adopters get
774
+ * and the shape nothing used to run.
775
+ *
776
+ * 1. `../scripts/anchor-resolve.js` — a repo checkout, preferred so a dev panel and CI cannot
777
+ * disagree about what resolves.
778
+ * 2. `lib/engine/anchor-resolve.js` — the copy `scripts/vendor-workspace-engine.js` vendors into
779
+ * the bundle (it is in the engine's own require closure, so it is already shipped). This is
780
+ * what an adopter runs. */
781
+ const ANCHOR_LIB_PATHS = [
782
+ path.join(__dirname, '..', 'scripts', 'anchor-resolve.js'),
783
+ path.join(__dirname, 'lib', 'engine', 'anchor-resolve.js'),
784
+ ];
748
785
  function anchorLib() {
749
- try { return require(path.join(__dirname, '..', 'scripts', 'anchor-resolve.js')); }
750
- catch { return null; }
786
+ for (const p of ANCHOR_LIB_PATHS) {
787
+ if (!fs.existsSync(p)) continue;
788
+ try { return require(p); } catch { return null; }
789
+ }
790
+ return null;
751
791
  }
752
792
  function surfaceOkr(root) {
753
793
  const A = anchorLib();
@@ -1045,7 +1085,7 @@ function unpersist(id) {
1045
1085
  }
1046
1086
  }
1047
1087
  // 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
1088
+ // setup, and that gets replayed verbatim into a fresh xterm.js on attach (public/pm/tab-term.js) — putting
1049
1089
  // the terminal straight back into the alternate buffer, where there is no scrollback. Strip it.
1050
1090
  //
1051
1091
  // The two signatures below are the client's, not an application's: the alt-screen switch IMMEDIATELY
@@ -2657,7 +2697,7 @@ function serveFile(res, file, type, noCache) {
2657
2697
  fs.readFile(file, (err, buf) => {
2658
2698
  if (err) { res.writeHead(404); res.end('not found'); return; }
2659
2699
  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
2700
+ // The app SHELL (index.html + the /pm/ module graph) must revalidate every load: without this a tab left open
2661
2701
  // across a `git merge` + server restart keeps running the pre-merge JS, so a fix that IS being
2662
2702
  // served never reaches the screen. `no-cache` = "always revalidate", not "never store".
2663
2703
  if (noCache) headers['Cache-Control'] = 'no-cache';
@@ -2678,7 +2718,10 @@ async function handleRequest(req, res) {
2678
2718
  const p = url.pathname;
2679
2719
 
2680
2720
  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);
2721
+ // NB: there is no `/app.js` route. The single-file front end that owned it (public/app.js +
2722
+ // public/styles.css) was RETIRED with the four-panel design — the shell is index.html plus the
2723
+ // /pm/ module graph below, and one front end is the point. A stray <script src="/app.js"> now
2724
+ // 404s loudly rather than silently resurrecting a second, diverging surface.
2682
2725
  if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
2683
2726
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
2684
2727
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
@@ -2689,7 +2732,8 @@ async function handleRequest(req, res) {
2689
2732
  // client feature that depends on it fails in a way no server-side test can see.
2690
2733
  if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
2691
2734
  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'));
2735
+ // …and no `/styles.css` route either: the token layer and every component rule now ship as
2736
+ // /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
2693
2737
  // Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
2694
2738
  // puts a permanent red line in the console and in this suite's `page errors seen` list — noise
2695
2739
  // that trains you to ignore the one place a real client-side failure would show up. An inline SVG
@@ -2711,7 +2755,28 @@ async function handleRequest(req, res) {
2711
2755
  // because the terminal renders bold — with only the regular face the browser synthesises it.
2712
2756
  if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
2713
2757
  if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
2758
+ // 500 joined 400/700 with the four-panel design, whose section labels and pills are medium-weight
2759
+ // mono. Without the face the browser synthesises it, and a synthesised weight is exactly the kind
2760
+ // of difference the design-parity comparator reports and a human eye does not.
2761
+ if (p === '/vendor/ibm-plex-mono-500.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-500.woff2'));
2714
2762
  if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
2763
+ // ── the four-panel front end (public/pm/) ──────────────────────────────────────────────────────
2764
+ // A DIRECTORY route, deliberately, where /lib/ above is an allowlist. The allowlist's own comment
2765
+ // records why it is a trap: a file missing from it 404s silently and the feature that needed it
2766
+ // fails somewhere no server-side test looks. That cost is tolerable for six stable modules and is
2767
+ // not tolerable for a front end split into fifteen, where every new module would be one forgotten
2768
+ // line away from a blank panel. The traversal guard is what makes the directory form safe: the
2769
+ // resolved path must still sit inside PM_DIR, so '..' segments and absolute-looking names cannot
2770
+ // reach a file outside it. Extension-restricted as well — this route serves code and styles, not
2771
+ // whatever else happens to land in the folder.
2772
+ if (p.startsWith('/pm/')) {
2773
+ const rel = p.slice('/pm/'.length);
2774
+ const file = path.resolve(PM_DIR, rel);
2775
+ if (!file.startsWith(PM_DIR + path.sep) || !['.js', '.css', '.json'].includes(path.extname(file))) {
2776
+ res.writeHead(404); return res.end('not found');
2777
+ }
2778
+ return serveFile(res, file, null, true);
2779
+ }
2715
2780
 
2716
2781
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
2717
2782
  if (p === '/api/workspaces' && req.method === 'POST') {
@@ -3260,6 +3325,87 @@ async function handleRequest(req, res) {
3260
3325
  if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
3261
3326
  return json(res, { name, ...out }, code);
3262
3327
  }
3328
+ // ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
3329
+ // Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
3330
+ // its sessions to answer a single question about a single initiative. Every one of those is
3331
+ // already produced somewhere here; what did not exist was the JOIN, so each client had to make
3332
+ // it — four round-trips per selection, and four chances for two surfaces to disagree about one
3333
+ // record. This route calls the SAME producers the existing endpoints call and joins them once.
3334
+ // The existing endpoints are deliberately unchanged: other surfaces still read them.
3335
+ //
3336
+ // GET → the assembled payload.
3337
+ // POST → append one declaration to a file-mode store (see fourPanel's header for why a link is
3338
+ // a declaration and never an outbound call).
3339
+ if (p === '/api/initiative' && req.method === 'GET') {
3340
+ const w = getWs(url.searchParams.get('ws'));
3341
+ const root = w ? w.root : SELF_ROOT;
3342
+ const id = url.searchParams.get('id') || '';
3343
+ // An id is a filename, not a path — the same guard the handoff surface applies, and the reason
3344
+ // this route can join a path without a traversal check at every use site.
3345
+ if (!fourPanel.validId(id)) {
3346
+ return json(res, { ok: false, error: 'an initiative id is required, and it must be a record name' }, 400);
3347
+ }
3348
+ const engine = consoleState.loadEngine();
3349
+ if (!engine.ok) return json(res, { ok: false, id, error: engine.error });
3350
+ const L = engine.engine.layout(root);
3351
+ // The ENGINE's frontmatter parser, not the workspace's — see lib/four-panel.js on why the two
3352
+ // disagree and why this endpoint's `frontmatter` has to agree with its own `ladder`.
3353
+ const rec = fourPanel.readRecord({ root, dir: L.initiatives, id, parse: engine.engine.parseLeadingFrontmatter });
3354
+ // A record that would not READ is the one fact the panel must show, and it is the whole
3355
+ // answer: deriving a ladder over a file that could not be parsed would be deriving over
3356
+ // nothing, and reporting the result beside the failure would invite reading it as data.
3357
+ let ladder = null, linksOut = null;
3358
+ if (rec.ok) {
3359
+ try { ladder = consoleState.ladder({ root, id }); }
3360
+ catch (e) { ladder = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
3361
+ // An ABSENT record declares nothing, so there is nothing for the handoff reader to read —
3362
+ // distinct from a record that declares no handoff, which readLinks reports as empty.
3363
+ if (!rec.absent) {
3364
+ try { linksOut = handoff.readLinks(rec.frontmatter || {}); }
3365
+ catch (e) { linksOut = null; void e; }
3366
+ }
3367
+ }
3368
+ let index = { initiatives: [] };
3369
+ try { if (wsShape(root) === 'pmos') index = initiativeIndex(root); }
3370
+ catch (e) { void e; }
3371
+ const stateDir = fourPanel.storeDir(engine.engine, root);
3372
+ const out = fourPanel.assemble({
3373
+ id, rec, ladder, links: linksOut, index,
3374
+ sessions: sessionList(), workspaces: wsList(),
3375
+ anchor: rec.ok && !rec.absent
3376
+ ? fourPanel.anchorFor({ anchors: anchorLib(), root, raw: (rec.frontmatter || {}).parent_kr })
3377
+ : null,
3378
+ artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3379
+ initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3380
+ });
3381
+ // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3382
+ return json(res, { ws: w ? w.id : null, ...out });
3383
+ }
3384
+ if (p === '/api/initiative/link' && req.method === 'POST') {
3385
+ // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3386
+ // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
3387
+ // and it cannot state anything about the destination's contents.
3388
+ const w = getWs(url.searchParams.get('ws'));
3389
+ let body = {};
3390
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3391
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3392
+ const w2 = body.ws ? getWs(body.ws) : w;
3393
+ const kind = String(body.store || 'artifact');
3394
+ const built = kind === 'initiative'
3395
+ ? fourPanel.initiativeLinkRow(body)
3396
+ : fourPanel.artifactLinkRow(body);
3397
+ if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
3398
+ const engine = consoleState.loadEngine();
3399
+ if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
3400
+ const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
3401
+ const wrote = fourPanel.appendDeclaration(
3402
+ stateDir,
3403
+ kind === 'initiative' ? fourPanel.INITIATIVE_LINKS : fourPanel.ARTIFACT_LINKS,
3404
+ built.row,
3405
+ );
3406
+ // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3407
+ return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
3408
+ }
3263
3409
  if (p === '/api/search') {
3264
3410
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
3265
3411
  // else here — an agent running in a pane can curl it to check what an earlier run tried.
@@ -3806,4 +3952,8 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
3806
3952
  trimBuf, SCROLLBACK,
3807
3953
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
3808
3954
  // asserted without a server that has no clients (the suite always has both).
3809
- shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };
3955
+ shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
3956
+ // where the anchor resolver is looked for — exported so the suite can assert the INSTALL
3957
+ // location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
3958
+ // exactly as it hid the workflow engine's absence for four releases (PKG4).
3959
+ ANCHOR_LIB_PATHS };