create-pmos 0.26.0 → 0.28.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.
@@ -795,6 +795,11 @@ function schedulePoll() {
795
795
  export async function loadRepos() {
796
796
  if (FIXTURE) return [];
797
797
  const out = await getJson('/api/workspaces');
798
+ // The server's one answer to "is the runtime state settled?", published straight onto state so
799
+ // panel 1 can say so. Under an unmigrated conflict the lists below are short by everything the
800
+ // per-user state directory does not hold, and a list that is short without saying so is the
801
+ // silent failure this key exists to end. `{ok:true}` publishes too — the banner reads `ok`.
802
+ const health = out && out.state_health ? out.state_health : null;
798
803
  const rows = foldWorkspaces(out && out.workspaces);
799
804
  productOrder = rows.map((r) => r.name);
800
805
  for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
@@ -808,6 +813,7 @@ export async function loadRepos() {
808
813
  // actually on screen. A restored selection from the PM's last session always wins.
809
814
  const sel = state.sel || (p ? firstSlugOf(p.name) : null);
810
815
  setState({
816
+ stateHealth: health,
811
817
  wsId: p ? p.ws : null,
812
818
  // NOT initialised to a value here. What is resolvable is the server's derivation from the PM's
813
819
  // agent profiles, it arrives with the first `/api/initiative` payload, and seeding a `[]` in the
@@ -942,6 +948,63 @@ export async function listFolder(dir) {
942
948
  return getJson('/api/fs/list', dir ? { path: dir } : null);
943
949
  }
944
950
 
951
+ /* ── what the session pre-flight asks the server ───────────────────────────────────────────────
952
+ * Four read-only endpoints that have been served all along with no client reading them. They are
953
+ * the questions the retired composer asked before a session started, and losing the client half is
954
+ * what left `+ session` spawning on defaults nobody chose.
955
+ *
956
+ * Every one declines in FIXTURE mode, and that is load-bearing rather than tidy: the fixture render
957
+ * must make no network call at all — it is what lets no-network.test.js and the design comparator
958
+ * trust a fixture run. The form degrades to "no repository was read" and the fixture's own profile
959
+ * list, which is a true statement about fixture mode rather than a blank.
960
+ */
961
+
962
+ /**
963
+ * Every branch of a workspace's repository, plus which one is current and which are already
964
+ * checked out in another working tree. Returns the server's `gitInfo.branchInfo` verbatim, so a
965
+ * non-repo answers `{ok:false, code, error}` and NEVER a fabricated branch — the difference
966
+ * between "this folder has no git" and "git failed" is the picker's to show, so it must survive
967
+ * the trip.
968
+ */
969
+ export async function loadBranches(ws) {
970
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
971
+ if (!ws) return { ok: false, error: 'no repository selected' };
972
+ return getJson('/api/git/branches', { ws });
973
+ }
974
+
975
+ /**
976
+ * The Claude profile descriptors, as `{ok, absent, dir, profiles, broken, prefs, error}`.
977
+ *
978
+ * All six fields matter to the caller and none may be flattened here: `absent` ("the directory does
979
+ * not exist") is a different sentence from a successful read that found none; `broken` names a
980
+ * descriptor that must be shown and NOT offered, while every good one beside it is still offered;
981
+ * and `prefs` is what preselects a chip, replacing a hardcoded 'claude-work' that named a file this
982
+ * client had never opened.
983
+ *
984
+ * `env` values are absent by construction on the server side (profileWire) — D08's discipline
985
+ * covers anything the browser receives, and an overlay is where a credential lives.
986
+ */
987
+ export async function loadProfiles() {
988
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no profiles' };
989
+ return getJson('/api/profiles');
990
+ }
991
+
992
+ /** Remember the profile this agent should preselect next time. The server refuses a preference for
993
+ * a descriptor it cannot read, so a stored id always resolves to a chip that is on screen. */
994
+ export async function preferProfile(agent, profile) {
995
+ if (FIXTURE) return { ok: false, error: 'fixture mode remembers nothing' };
996
+ return postJson('/api/profiles/prefer', { agent, profile });
997
+ }
998
+
999
+ /** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
1000
+ * checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
1001
+ * rather than as an error after the click. */
1002
+ export async function loadWorktrees(ws) {
1003
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
1004
+ if (!ws) return { ok: false, error: 'no repository selected' };
1005
+ return getJson('/api/git/worktrees', { ws });
1006
+ }
1007
+
945
1008
  /**
946
1009
  * panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
947
1010
  *
@@ -1030,6 +1093,23 @@ export async function readDoc(path) {
1030
1093
  return res.text();
1031
1094
  }
1032
1095
 
1096
+ /**
1097
+ * panel-initiatives.js — `searchFiles(q, limit)`. Every file under THIS workspace's root whose path
1098
+ * or contents match, from the one endpoint that walks the folder. Bounded on the server, and the
1099
+ * bound comes back in the payload (`truncated`, `scanned`) rather than being silently applied — a
1100
+ * capped list rendered as a complete one is the defect the search box already had.
1101
+ *
1102
+ * FIXTURE mode resolves to an empty, untruncated result WITHOUT fetching: rule 1 of this file is
1103
+ * that a fixture render is provably not a real read wearing a costume, and `no-network.test.js`
1104
+ * checks that claim at source level.
1105
+ */
1106
+ export async function searchFiles(query, limit) {
1107
+ const q = String(query == null ? '' : query).trim();
1108
+ if (FIXTURE || !q) return { ok: true, q, hits: [], truncated: false, scanned: 0 };
1109
+ const p = products.get(currentProductName());
1110
+ return getJson('/api/search/files', { ws: state.wsId || (p ? p.ws : ''), q, limit });
1111
+ }
1112
+
1033
1113
  /**
1034
1114
  * tab-doc.js — `saveDoc({ws, path, base, text})`. Refuse-on-stale: the server compares `base`
1035
1115
  * against the file on disk and REFUSES rather than overwriting a file that changed underneath.
@@ -264,7 +264,14 @@ export function openTerm(slug, sess) {
264
264
  const key = slug ? 'term:' + slug + ':' + sess.name : 'term:sess:' + ((sess && sess.id) || sess.name);
265
265
  openTab({
266
266
  id: key, kind: 'term', title: sess.name, slug: slug || null,
267
- repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
267
+ /* `profile` is the session's OWN, or null — never the literal 'claude-work'. It used to default
268
+ * to that string, which was a claim about a descriptor this client had never read: nothing in
269
+ * data.js reads /api/profiles, so the name was invented here and then filed on the session. It
270
+ * also became load-bearing the moment the tab's profile started travelling to the server on a
271
+ * spawn, because `resolveProfileForSpawn` REFUSES a profile it cannot find — a fabricated
272
+ * default would have turned "no profile chosen" into "session refused". Every display reader
273
+ * (fixture.js:853, modes.js:658/808) already supplies its own fallback for null. */
274
+ repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || null,
268
275
  /* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
269
276
  * with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
270
277
  * to the same one again. Without it the tab falls back to matching by NAME, and names repeat
@@ -278,7 +285,13 @@ export function openTerm(slug, sess) {
278
285
  export function openDoc(slug, path, role) {
279
286
  openTab({
280
287
  id: 'doc:' + path, kind: 'doc', title: path.split('/').pop(), slug,
281
- repo: slugProduct()[slug], path, role: role || 'DOC',
288
+ /* A file opened from the FOLDER search belongs to no initiative, so `slugProduct()` has no
289
+ * answer for it — and `panel-tabs.visibleTabs` shows a tab whose repo is `undefined` in EVERY
290
+ * repository, which would hang this workspace's file in another one's tab strip and read its
291
+ * path against that root. The repository it was opened FROM is the true answer, and it is used
292
+ * only where there is no slug: every existing call site keeps its exact previous value. */
293
+ repo: slug ? slugProduct()[slug] : currentProduct(),
294
+ path, role: role || 'DOC',
282
295
  });
283
296
  }
284
297
 
@@ -488,6 +501,147 @@ function hitNode(ht) {
488
501
  );
489
502
  }
490
503
 
504
+ /* ── "files in this folder" — the half of `search everything…` that was missing ────────────────
505
+ *
506
+ * `searchHits` above is the board's search: initiative slugs, paths DECLARED on a record, session
507
+ * names. A file the board was never told about is in none of those lists, so it could not be found
508
+ * by typing its exact name — and the miss printed "nothing matches — a successful read of nothing",
509
+ * which is this repo's phrase for *we looked and there was nothing*, after a search that had never
510
+ * opened the folder. This block is the folder half, from /api/search.
511
+ *
512
+ * ── Three constraints the shape below is a response to ──────────────────────────────────────
513
+ *
514
+ * 1. THE SEARCH IS ASYNCHRONOUS AND THE RENDER IS NOT. `state.q` changes on every keystroke and
515
+ * every change repaints this panel, so a result has to arrive by a route that survives a
516
+ * re-render: a cache keyed by the QUERY, a fetch kicked off during render, and `setState({})` on
517
+ * arrival. It is exactly `tab-doc.js`'s `requestDoc` pattern, for the same reason.
518
+ * 2. ONE WORD IS ONE SEARCH, NOT SIX. A 140 ms debounce collapses the keystrokes of a word, and
519
+ * only the LATEST query is ever in flight — an earlier reply landing late must not repaint the
520
+ * panel with results for a query the PM has already typed past.
521
+ * 3. A BOUNDED ANSWER SAYS SO. `truncated` travels from the walk to the payload to this heading. A
522
+ * capped list drawn as a complete one is the same lie with a longer walk behind it.
523
+ *
524
+ * A hit is labelled `file · <kind>`, never `artifact · <role>`: a file no initiative declares has no
525
+ * role, and inventing one would file a claim nobody made. A file the reader cannot show (an image,
526
+ * an inert binary) is LISTED and greyed rather than hidden — found-but-not-openable is a true
527
+ * answer, and it beats a tab that opens onto a failure the surface could have predicted.
528
+ */
529
+
530
+ const fileCache = new Map(); // q -> { hits, truncated, scanned, error }
531
+ let fileWanted = null; // the query the PM has actually typed — the only reply that may land
532
+ let fileTimer = null;
533
+ let dataMod; // undefined = not tried · null = unavailable · module = ready
534
+
535
+ function dataModule() {
536
+ if (dataMod !== undefined) return dataMod;
537
+ dataMod = null;
538
+ import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
539
+ return null;
540
+ }
541
+
542
+ function requestFiles(q) {
543
+ fileWanted = q;
544
+ if (fileTimer) clearTimeout(fileTimer);
545
+ fileTimer = setTimeout(() => {
546
+ fileTimer = null;
547
+ const mod = dataModule();
548
+ if (!mod || typeof mod.searchFiles !== 'function') return;
549
+ const asked = q;
550
+ Promise.resolve(mod.searchFiles(asked)).then(
551
+ (r) => {
552
+ if (fileWanted !== asked) return; // the PM has typed past this query
553
+ fileCache.set(asked, (r && r.ok === false)
554
+ ? { hits: [], truncated: false, contentTruncated: false, scanned: 0, contentScanned: 0, error: r.error || 'the folder could not be searched' }
555
+ : {
556
+ hits: (r && r.hits) || [], truncated: !!(r && r.truncated),
557
+ contentTruncated: !!(r && r.content_truncated),
558
+ scanned: (r && r.scanned) || 0, contentScanned: (r && r.content_scanned) || 0, error: null,
559
+ });
560
+ if (fileCache.size > 40) fileCache.delete(fileCache.keys().next().value);
561
+ setState({});
562
+ },
563
+ (e) => {
564
+ if (fileWanted !== asked) return;
565
+ fileCache.set(asked, { hits: [], truncated: false, contentTruncated: false, scanned: 0, contentScanned: 0, error: (e && e.message) || String(e) });
566
+ setState({});
567
+ },
568
+ );
569
+ }, 140);
570
+ }
571
+
572
+ /** The result for `q`, or null while nothing has come back yet. Kicks the fetch off on a miss. */
573
+ function fileResults(q) {
574
+ if (!q || FIXTURE) return null;
575
+ if (fileCache.has(q)) return fileCache.get(q);
576
+ requestFiles(q);
577
+ return null;
578
+ }
579
+
580
+ const fileLabel = (h) => 'file · ' + (h.kind || 'file') + (h.match === 'content' ? ' · line ' + h.line : '');
581
+
582
+ function fileHitNode(h) {
583
+ const where = h.dir ? h.dir + '/' : '';
584
+ const sub = h.match === 'content' ? h.snippet : (where + h.name);
585
+ if (!h.openable) {
586
+ return el('div', {
587
+ style: 'display:block;width:100%;padding:6px 12px;min-height:40px;opacity:.55',
588
+ title: h.path + '\nfound, but this workspace\'s reader cannot show a ' + h.kind + ' file — nothing was opened.',
589
+ },
590
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(fileLabel(h) + ' · not openable')),
591
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:11px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(h.path)),
592
+ );
593
+ }
594
+ return el('button', {
595
+ onClick: () => openDoc(null, h.path, 'FILE'),
596
+ style: 'display:block;width:100%;padding:6px 12px;min-height:40px;text-align:left',
597
+ hover: 'background:var(--accent)',
598
+ title: h.path,
599
+ },
600
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--info)' }, iv(fileLabel(h))),
601
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:11px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(h.name)),
602
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(sub)),
603
+ );
604
+ }
605
+
606
+ /**
607
+ * The whole block, or null when there is nothing to say. Rendered in EVERY mode — the box says
608
+ * `search everything…` in all seven of them, and modes.js reads `state.q` in none.
609
+ */
610
+ export function fileHitsBlock(q) {
611
+ if (!q || FIXTURE) return null;
612
+ const r = fileResults(q);
613
+ const head = (text, tone) => el('div', {
614
+ style: 'padding:4px 12px 4px;font-family:var(--mono);font-size:10px;letter-spacing:.12em;'
615
+ + 'text-transform:uppercase;color:' + (tone || 'var(--muted-foreground)') + ';border-bottom:1px solid var(--border)',
616
+ }, iv(text));
617
+
618
+ if (!r) return el('div', { 'data-pm-files': '' }, head('files in this folder · searching…'));
619
+ if (r.error) {
620
+ return el('div', { 'data-pm-files': '' }, head('files in this folder · not searched', 'var(--destructive)'),
621
+ el('div', { style: 'padding:6px 12px;font-size:11px;color:var(--muted-foreground)' }, iv(r.error)));
622
+ }
623
+ /* WHAT THE WALK DID NOT DO, in the heading, in the caller's own words. Two flags, never merged:
624
+ * `truncated` means files were never looked at AT ALL — the one that changes what a PM should
625
+ * conclude from an empty list. `contentTruncated` means every file was NAMED and some bodies
626
+ * were not read, which is the ordinary state of any repository holding a lock file. Folding the
627
+ * second into the first would light the serious flag permanently and thereby retire it. */
628
+ const bound = r.truncated
629
+ ? ' · BOUNDED — files beyond the walk limit were not looked at'
630
+ : (r.contentTruncated ? ' · all ' + r.scanned + ' names searched; ' + r.contentScanned + ' bodies read' : '');
631
+
632
+ if (!r.hits.length) {
633
+ // The board's own phrase is used ONLY when it is true — when the walk really did reach
634
+ // everything. A cut-short walk found nothing *of what it looked at*, which is a different claim.
635
+ return el('div', { 'data-pm-files': '' },
636
+ head('files in this folder · ' + (r.truncated
637
+ ? 'no match in the ' + r.scanned + ' files searched — the walk was BOUNDED, more were not looked at'
638
+ : 'no file matches — a successful read of nothing' + (r.contentTruncated ? bound : ''))));
639
+ }
640
+ const kids = [head('files in this folder · ' + r.hits.length + ' of ' + r.scanned + ' searched' + bound)];
641
+ for (const h of r.hits) kids.push(fileHitNode(h));
642
+ return el('div', { 'data-pm-files': '' }, kids);
643
+ }
644
+
491
645
  /* ── the file-new form's view model (prototype `nw`, lines 1620–1687) ──────────────────────────
492
646
  * The form itself renders in PANEL 3 (`detail.kind === 'new'`), which panel-detail.js owns — but
493
647
  * every field it edits and every record it files belongs to this board, so the model is derived
@@ -603,7 +757,10 @@ export function renderInitiatives(host) {
603
757
  if (searchInput.value !== state.q) searchInput.value = state.q;
604
758
 
605
759
  if (mode !== 'product') {
606
- setChildren(host, [header, searchBox]);
760
+ // The folder results render HERE TOO. The box says `search everything…` in all seven modes and
761
+ // modes.js reads `state.q` in none of them, so until now typing in six of them did nothing at
762
+ // all — including in the library/folder lens, the one surface that actually lists files.
763
+ setChildren(host, [header, searchBox, fileHitsBlock(state.q || '')]);
607
764
  const modes = modesModule();
608
765
  if (modes && typeof modes.renderMode === 'function') {
609
766
  try { modes.renderMode(host, null, mode); } catch (e) { console.error('[pm] renderMode failed:', e); }
@@ -685,7 +842,15 @@ export function renderInitiatives(host) {
685
842
  'matches elsewhere'));
686
843
  for (const ht of hits) kids.push(hitNode(ht));
687
844
  }
688
- if (total === 0 && hits.length === 0) {
845
+ // THE FOLDER. Below the board's own hits, because a record the PM filed outranks a file that
846
+ // merely matched — and it is the block that makes the box's name true.
847
+ const files = fileHitsBlock(q);
848
+ if (files) kids.push(files);
849
+ // The prototype's line, unchanged and shown under the same condition it always was — plus the
850
+ // folder. It claims nothing matched ANYWHERE, so a file hit has to suppress it or it is a lie
851
+ // rendered directly above its own counter-example.
852
+ const fileRes = fileResults(q);
853
+ if (total === 0 && hits.length === 0 && !(fileRes && fileRes.hits.length)) {
689
854
  kids.push(el('div', { style: 'padding:10px 14px;font-size:12px;color:var(--muted-foreground);font-style:italic' },
690
855
  'nothing matches — a successful read of nothing'));
691
856
  }