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.
- package/README.md +26 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +39 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/drive-guard.js +165 -30
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +80 -0
- package/workspace/public/pm/panel-initiatives.js +169 -4
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +370 -26
- package/workspace/test-run.js +84 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|