create-pmos 0.27.0 → 0.29.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/payload/scripts/pmos-log.sh +29 -2
- package/payload/scripts/workflow-state.js +40 -6
- package/payload/skills/evaluator.skill +45 -12
- package/payload/skills/pmos-router.skill +1 -1
- package/payload/skills-native/evaluator/SKILL.md +45 -12
- package/payload/skills-native/pmos-router/SKILL.md +1 -1
- package/workspace/README.md +22 -0
- package/workspace/lib/drive-guard.js +421 -30
- package/workspace/lib/engine/workflow-state.js +40 -6
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +17 -0
- package/workspace/public/pm/panel-initiatives.js +161 -3
- package/workspace/server.js +246 -6
|
@@ -285,7 +285,13 @@ export function openTerm(slug, sess) {
|
|
|
285
285
|
export function openDoc(slug, path, role) {
|
|
286
286
|
openTab({
|
|
287
287
|
id: 'doc:' + path, kind: 'doc', title: path.split('/').pop(), slug,
|
|
288
|
-
|
|
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',
|
|
289
295
|
});
|
|
290
296
|
}
|
|
291
297
|
|
|
@@ -495,6 +501,147 @@ function hitNode(ht) {
|
|
|
495
501
|
);
|
|
496
502
|
}
|
|
497
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
|
+
|
|
498
645
|
/* ── the file-new form's view model (prototype `nw`, lines 1620–1687) ──────────────────────────
|
|
499
646
|
* The form itself renders in PANEL 3 (`detail.kind === 'new'`), which panel-detail.js owns — but
|
|
500
647
|
* every field it edits and every record it files belongs to this board, so the model is derived
|
|
@@ -610,7 +757,10 @@ export function renderInitiatives(host) {
|
|
|
610
757
|
if (searchInput.value !== state.q) searchInput.value = state.q;
|
|
611
758
|
|
|
612
759
|
if (mode !== 'product') {
|
|
613
|
-
|
|
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 || '')]);
|
|
614
764
|
const modes = modesModule();
|
|
615
765
|
if (modes && typeof modes.renderMode === 'function') {
|
|
616
766
|
try { modes.renderMode(host, null, mode); } catch (e) { console.error('[pm] renderMode failed:', e); }
|
|
@@ -692,7 +842,15 @@ export function renderInitiatives(host) {
|
|
|
692
842
|
'matches elsewhere'));
|
|
693
843
|
for (const ht of hits) kids.push(hitNode(ht));
|
|
694
844
|
}
|
|
695
|
-
|
|
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)) {
|
|
696
854
|
kids.push(el('div', { style: 'padding:10px 14px;font-size:12px;color:var(--muted-foreground);font-style:italic' },
|
|
697
855
|
'nothing matches — a successful read of nothing'));
|
|
698
856
|
}
|
package/workspace/server.js
CHANGED
|
@@ -782,6 +782,203 @@ function looseDocs(root) {
|
|
|
782
782
|
}
|
|
783
783
|
return out;
|
|
784
784
|
}
|
|
785
|
+
/* ── SEARCH: the corpus `search everything…` is finally allowed to be ─────────────────────────
|
|
786
|
+
*
|
|
787
|
+
* The box shipped searching three DERIVED lists — initiative slugs, paths declared on an initiative
|
|
788
|
+
* record, and session names — so a file the board was never told about could not be found by typing
|
|
789
|
+
* its exact name. Worse, a miss rendered "nothing matches — a successful read of nothing", this
|
|
790
|
+
* repo's phrase for *we looked and there was nothing*, after a search that never opened the folder.
|
|
791
|
+
*
|
|
792
|
+
* Four rules hold this honest, and each one is asserted in the suite:
|
|
793
|
+
*
|
|
794
|
+
* 1. THE WALK IS THE FOLDER LENS'S WALK. Same `SKIP_DIRS`, same dotted-name exclusion, so search
|
|
795
|
+
* and the lens can never disagree about what exists in this folder. A third answer to "what is
|
|
796
|
+
* in here" would be worse than the missing feature.
|
|
797
|
+
* 2. NOTHING NEW IS REACHABLE. Every emitted path is `path.relative(root, …)` from a walk that
|
|
798
|
+
* STARTS at the root and only descends through `e.isDirectory()`. `readdirSync(…,
|
|
799
|
+
* {withFileTypes:true})` reports a symlink as `isSymbolicLink()` — neither a file nor a
|
|
800
|
+
* directory — so a link pointing out of the workspace is never followed and never emitted.
|
|
801
|
+
* There is no user-supplied path parameter here at all: the query is a needle, not a location.
|
|
802
|
+
* 3. IT IS BOUNDED, AND THE BOUND IS IN THE PAYLOAD. A capped list rendered as a complete one is
|
|
803
|
+
* the same lie the box already told, with a longer walk behind it — so every cap reports
|
|
804
|
+
* itself, and it must be FALSE when nothing was cut. The suite asserts both directions,
|
|
805
|
+
* because a flag that is always true is not a flag.
|
|
806
|
+
*
|
|
807
|
+
* TWO caps, TWO flags, deliberately not one. `truncated` means *files you searched for were
|
|
808
|
+
* never looked at* (the walk budget, the depth, or the result limit). `content_truncated`
|
|
809
|
+
* means *every file was named, but not every body was read* — which is the ordinary state of
|
|
810
|
+
* any repository carrying a lock file, and collapsing it into `truncated` would light the
|
|
811
|
+
* serious flag permanently and thereby retire it.
|
|
812
|
+
* 4. `kind` IS preview-kind's ANSWER, NOT A SECOND OPINION. The same classifier the read endpoints
|
|
813
|
+
* and the tree flag already use decides whether the reader can show a hit, so search can say
|
|
814
|
+
* "found, but not openable" instead of handing the PM a tab that prints a failure.
|
|
815
|
+
*/
|
|
816
|
+
const SEARCH_MAX_FILES = 20000; // walk budget — a giant repo (or a Drive mount) never hangs the box
|
|
817
|
+
const SEARCH_MAX_DEPTH = 12; // deeper than the lens's 6: search renders no tree, so depth is cheap
|
|
818
|
+
const SEARCH_CONTENT_FILES = 4000; // how many readable files get their BODIES scanned
|
|
819
|
+
const SEARCH_CONTENT_BYTES = 256 * 1024; // per file; a bigger one is path-matched only, never read whole
|
|
820
|
+
const SEARCH_LIMIT_DEFAULT = 40;
|
|
821
|
+
const SEARCH_LIMIT_MAX = 200;
|
|
822
|
+
const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
|
|
823
|
+
const SEARCH_SNIPPET = 160;
|
|
824
|
+
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
|
|
825
|
+
const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
|
|
826
|
+
|
|
827
|
+
/** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
|
|
828
|
+
function searchIndex(root) {
|
|
829
|
+
const now = Date.now();
|
|
830
|
+
const hit = searchIndexCache.get(root);
|
|
831
|
+
if (hit && now - hit.at < SEARCH_INDEX_TTL_MS) return hit;
|
|
832
|
+
const files = [];
|
|
833
|
+
let depthCut = false; // some branch was too deep — a LOCAL fact
|
|
834
|
+
let budgetCut = false; // the file budget ran out — a GLOBAL stop
|
|
835
|
+
/* Two truncation causes, two flags, and only ONE of them stops the walk.
|
|
836
|
+
*
|
|
837
|
+
* They used to share `truncated`, and the guard at the top of `walk` read it — so the first branch
|
|
838
|
+
* that hit SEARCH_MAX_DEPTH aborted every not-yet-visited subdirectory in the whole tree. Measured
|
|
839
|
+
* by an independent evaluator on 2026-08-04: 51 files at depth <= 1, `scanned: 50`, an entire
|
|
840
|
+
* in-bound sibling directory lost because an unrelated deep branch tripped the cap first — and
|
|
841
|
+
* which sibling survived depended on readdir order.
|
|
842
|
+
*
|
|
843
|
+
* Depth is a bound on ONE path. The file budget is a bound on the WHOLE walk. Only the second is
|
|
844
|
+
* a reason to stop walking. */
|
|
845
|
+
(function walk(dir, rel, depth) {
|
|
846
|
+
if (budgetCut) return;
|
|
847
|
+
let ents = [];
|
|
848
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
849
|
+
for (const e of ents) {
|
|
850
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
851
|
+
const full = path.join(dir, e.name);
|
|
852
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
853
|
+
if (e.isDirectory()) {
|
|
854
|
+
// Depth is a bound like any other: reaching it means files below were NOT looked at —
|
|
855
|
+
// but only BELOW HERE. Siblings elsewhere in the tree are still in bounds.
|
|
856
|
+
if (depth < SEARCH_MAX_DEPTH) walk(full, r, depth + 1);
|
|
857
|
+
else depthCut = true;
|
|
858
|
+
} else if (e.isFile()) {
|
|
859
|
+
if (files.length >= SEARCH_MAX_FILES) { budgetCut = true; return; }
|
|
860
|
+
files.push({ path: r, name: e.name, dir: rel, kind: preview.previewKind(e.name) });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
})(root, '', 0);
|
|
864
|
+
const entry = { at: now, files, truncated: depthCut || budgetCut, scanned: files.length };
|
|
865
|
+
searchIndexCache.set(root, entry);
|
|
866
|
+
return entry;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
|
|
870
|
+
function searchSnippet(line, at) {
|
|
871
|
+
const s = String(line || '').replace(/\t/g, ' ');
|
|
872
|
+
if (s.length <= SEARCH_SNIPPET) return s.trim();
|
|
873
|
+
const start = Math.max(0, at - Math.floor(SEARCH_SNIPPET / 3));
|
|
874
|
+
return (start > 0 ? '…' : '') + s.slice(start, start + SEARCH_SNIPPET).trim()
|
|
875
|
+
+ (start + SEARCH_SNIPPET < s.length ? '…' : '');
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Search one workspace. Path matches first (they are what the PM typed a name for), then content
|
|
880
|
+
* matches over the readable text files that did not already match by path.
|
|
881
|
+
* `{ ok, q, hits, truncated, content_truncated, scanned, content_scanned, limit }` — never throws,
|
|
882
|
+
* never 404s: an empty `hits` with `truncated:false` IS the honest "looked, found nothing".
|
|
883
|
+
*/
|
|
884
|
+
function searchWorkspace(root, query, limit) {
|
|
885
|
+
const q = String(query == null ? '' : query).trim();
|
|
886
|
+
const cap = Math.max(1, Math.min(SEARCH_LIMIT_MAX, parseInt(limit, 10) || SEARCH_LIMIT_DEFAULT));
|
|
887
|
+
if (!q) {
|
|
888
|
+
return { ok: true, q: '', hits: [], truncated: false, content_truncated: false,
|
|
889
|
+
scanned: 0, content_scanned: 0, limit: cap };
|
|
890
|
+
}
|
|
891
|
+
const needle = q.toLowerCase();
|
|
892
|
+
const idx = searchIndex(root);
|
|
893
|
+
const hits = [];
|
|
894
|
+
const seen = new Set();
|
|
895
|
+
let overflow = false; // more matched than `cap` — a REAL cut, not a coincidence
|
|
896
|
+
|
|
897
|
+
/* `openable` means ONE thing: /api/doc will serve this. So it asks /api/doc's own reader rather
|
|
898
|
+
* than re-deciding from the extension.
|
|
899
|
+
*
|
|
900
|
+
* It used to be `SEARCH_OPENABLE.has(f.kind)` — extension only — while `readTextDoc` applies a
|
|
901
|
+
* SECOND refusal the search never replicated: a NUL byte anywhere in the file. A `.txt` holding
|
|
902
|
+
* binary therefore rendered as a clickable hit that opened a tab printing a read failure, which
|
|
903
|
+
* is the PRD's own constraint 6 violated ("the result says so rather than letting the PM click
|
|
904
|
+
* into a predictable failure"). An independent evaluator reproduced it on 2026-08-04:
|
|
905
|
+
*
|
|
906
|
+
* hit masquerade.txt kind=text openable=true -> /api/doc status=404
|
|
907
|
+
*
|
|
908
|
+
* Two notions of "servable" that can disagree is the defect; one definition, consulted, is the
|
|
909
|
+
* fix. Bounded by `cap` (<= 200 hits), and only for path matches — a content match already read
|
|
910
|
+
* the body and proved it NUL-free. */
|
|
911
|
+
const servable = (rel) => {
|
|
912
|
+
try {
|
|
913
|
+
/* SIZE-BOUNDED. `readTextDoc` has no size limit, so consulting it for every path hit read up
|
|
914
|
+
* to `cap` (200) WHOLE files on the server's only thread — measured by an independent verifier
|
|
915
|
+
* at 285 ms for 200 × 1 MB, and 5.7 s plus a 600 MB read for one oversized file. The comment
|
|
916
|
+
* twelve lines above SEARCH_CONTENT_BYTES says a bigger file is "path-matched only, never read
|
|
917
|
+
* whole"; the fix quietly broke that. A file past the content budget reports NOT openable —
|
|
918
|
+
* the honest answer is "we did not look", and the safe rendering of that is a hit the PM
|
|
919
|
+
* cannot click into an unknown. */
|
|
920
|
+
const st = fs.statSync(path.join(root, rel));
|
|
921
|
+
if (!st.isFile() || st.size > SEARCH_CONTENT_BYTES) return false;
|
|
922
|
+
return readTextDoc(root, rel) !== null;
|
|
923
|
+
} catch { return false; }
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
for (const f of idx.files) {
|
|
927
|
+
if (!f.path.toLowerCase().includes(needle)) continue;
|
|
928
|
+
seen.add(f.path);
|
|
929
|
+
if (hits.length >= cap) { overflow = true; break; }
|
|
930
|
+
hits.push({ ...f, match: 'path', line: null, snippet: null,
|
|
931
|
+
openable: SEARCH_OPENABLE.has(f.kind) && servable(f.path) });
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let read = 0;
|
|
935
|
+
let contentCut = false;
|
|
936
|
+
/* When path matches overflow the cap, the ENTIRE content pass is skipped — so every body in the
|
|
937
|
+
* workspace went unread. `content_truncated` reported `false` alongside `content_scanned: 0`,
|
|
938
|
+
* contradicting the flag's own documented meaning ("every file was named, but not every body was
|
|
939
|
+
* read"). Masked by `truncated: true`, so the PM was warned — but a flag that disagrees with
|
|
940
|
+
* reality is the failure the two-flag split exists to prevent. */
|
|
941
|
+
if (overflow) contentCut = true;
|
|
942
|
+
if (!overflow) {
|
|
943
|
+
for (const f of idx.files) {
|
|
944
|
+
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
|
|
945
|
+
if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
|
|
946
|
+
const full = path.join(root, f.path);
|
|
947
|
+
let st;
|
|
948
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
949
|
+
if (!st.isFile()) continue;
|
|
950
|
+
if (st.size > SEARCH_CONTENT_BYTES) { contentCut = true; continue; }
|
|
951
|
+
read += 1;
|
|
952
|
+
let text;
|
|
953
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
954
|
+
if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary masquerading as text — never scanned
|
|
955
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
956
|
+
if (at === -1) continue;
|
|
957
|
+
// The cap biting HERE also leaves bodies unread — the sibling branch of the same defect, and
|
|
958
|
+
// the half the first fix missed.
|
|
959
|
+
if (hits.length >= cap) { overflow = true; contentCut = true; break; }
|
|
960
|
+
const before = text.slice(0, at);
|
|
961
|
+
const lineNo = before.split('\n').length;
|
|
962
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
963
|
+
const lineEnd = text.indexOf('\n', at);
|
|
964
|
+
hits.push({
|
|
965
|
+
...f, match: 'content', line: lineNo,
|
|
966
|
+
snippet: searchSnippet(text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd), at - lineStart),
|
|
967
|
+
openable: true,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
ok: true, q, hits, limit: cap,
|
|
974
|
+
// "files you searched for were never looked at" — the serious one.
|
|
975
|
+
truncated: idx.truncated || overflow,
|
|
976
|
+
// "every file was named; not every body was read" — the ordinary one.
|
|
977
|
+
content_truncated: contentCut,
|
|
978
|
+
scanned: idx.scanned, content_scanned: read,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
785
982
|
// Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
|
|
786
983
|
// per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
|
|
787
984
|
// non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
|
|
@@ -1136,10 +1333,32 @@ function deriveState(ev) {
|
|
|
1136
1333
|
// state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
|
|
1137
1334
|
// "one field, several readers, none agreeing" defect the anchor resolver was built to end.
|
|
1138
1335
|
function acceptanceFilesFor(w) {
|
|
1336
|
+
return ledgerFilesFor(w, 'acceptances.jsonl');
|
|
1337
|
+
}
|
|
1338
|
+
// The general form of the comment above, because the defect it describes was never specific to
|
|
1339
|
+
// acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
|
|
1340
|
+
// state dir alone, so the supervision surface under-reported everything pmos-self records in
|
|
1341
|
+
// planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
|
|
1342
|
+
// One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
|
|
1343
|
+
function ledgerFilesFor(w, basename) {
|
|
1139
1344
|
const out = [];
|
|
1140
1345
|
const d = stateDirFor(w);
|
|
1141
|
-
if (d) out.push(path.join(d,
|
|
1142
|
-
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs',
|
|
1346
|
+
if (d) out.push(path.join(d, basename));
|
|
1347
|
+
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', basename));
|
|
1348
|
+
return out;
|
|
1349
|
+
}
|
|
1350
|
+
// readJsonl over BOTH ledger locations, deduped by nothing: callers fold, and a row present in both
|
|
1351
|
+
// is the same row recorded twice, which every existing fold already tolerates.
|
|
1352
|
+
function readLedger(w, basename) {
|
|
1353
|
+
const out = [];
|
|
1354
|
+
for (const f of ledgerFilesFor(w, basename)) {
|
|
1355
|
+
let text = '';
|
|
1356
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
1357
|
+
for (const line of text.split('\n')) {
|
|
1358
|
+
if (!line.trim()) continue;
|
|
1359
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn line is reported by the checkers, not here */ }
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1143
1362
|
return out;
|
|
1144
1363
|
}
|
|
1145
1364
|
function readAcceptances(w) {
|
|
@@ -2794,18 +3013,20 @@ function deriveNeeds(w) {
|
|
|
2794
3013
|
if (!dir) return [];
|
|
2795
3014
|
const needs = [];
|
|
2796
3015
|
const settled = new Set();
|
|
2797
|
-
for (const a of
|
|
2798
|
-
for (const e of
|
|
3016
|
+
for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
|
|
3017
|
+
for (const e of readLedger(w, 'evals.jsonl')) {
|
|
2799
3018
|
if (!e.run_id || settled.has(e.run_id)) continue;
|
|
2800
3019
|
const verdict = e.verdict ? String(e.verdict) : 'no verdict';
|
|
2801
3020
|
needs.push({
|
|
2802
3021
|
run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
|
|
2803
3022
|
question: `Acceptance Gate open — evaluator says ${verdict}${e.score != null ? ` (${e.score})` : ''}`,
|
|
2804
|
-
|
|
3023
|
+
// `rubric` is the deprecated alias every one of the 26 legacy rows uses; scripts/record-eval.js
|
|
3024
|
+
// requires `rubric_path` going forward. Read both, write only the new one.
|
|
3025
|
+
doc: e.rubric_path || e.rubric || null,
|
|
2805
3026
|
});
|
|
2806
3027
|
}
|
|
2807
3028
|
const disposed = new Set();
|
|
2808
|
-
for (const c of
|
|
3029
|
+
for (const c of readLedger(w, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
|
|
2809
3030
|
const today = nowISO().slice(0, 10);
|
|
2810
3031
|
let files = [];
|
|
2811
3032
|
try { files = fs.readdirSync(path.join(dir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch {}
|
|
@@ -4309,6 +4530,25 @@ async function handleRequest(req, res) {
|
|
|
4309
4530
|
});
|
|
4310
4531
|
return json(res, built, built.ok ? 200 : 400);
|
|
4311
4532
|
}
|
|
4533
|
+
// ── TWO CORPORA, TWO ENDPOINTS, ONE BOX ────────────────────────────────────────────────────
|
|
4534
|
+
// `/api/search` is EPISODIC — what a past session printed. `/api/search/files` is the FOLDER —
|
|
4535
|
+
// what is on disk under this workspace's root. They are deliberately not merged: the hit shapes
|
|
4536
|
+
// share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
|
|
4537
|
+
// search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
|
|
4538
|
+
// Collapsing them into one payload would mean one of the two lying about what it looked at.
|
|
4539
|
+
if (p === '/api/search/files') {
|
|
4540
|
+
// `search everything…`, finally over everything: every file under THIS workspace's root, by
|
|
4541
|
+
// path and by content. There is no path parameter — the query is a needle, not a location — so
|
|
4542
|
+
// this adds no reachable path; `openable` reports whether /api/doc (or /api/asset) could show a
|
|
4543
|
+
// hit. `truncated` + `scanned` are the contract that keeps a bounded answer from reading as a
|
|
4544
|
+
// complete one, which is the defect this endpoint exists to end rather than to relocate.
|
|
4545
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4546
|
+
if (!w) {
|
|
4547
|
+
return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
|
|
4548
|
+
hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
|
|
4549
|
+
}
|
|
4550
|
+
return json(res, { ws: w.id, ...searchWorkspace(w.root, url.searchParams.get('q'), url.searchParams.get('limit')) });
|
|
4551
|
+
}
|
|
4312
4552
|
if (p === '/api/search') {
|
|
4313
4553
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
4314
4554
|
// else here — an agent running in a pane can curl it to check what an earlier run tried.
|