create-pmos 0.27.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 CHANGED
@@ -333,6 +333,32 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.28.0
337
+
338
+ **`search everything…` now searches every file in the folder, and any file it finds opens in a tab.**
339
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **The box searched three lists, and the folder was not one of them.** It matched initiative slugs,
342
+ paths *declared on* an initiative record, and session names — so a file PMOS had never been told
343
+ about could not be found by typing its exact name. Worse, the miss read *"nothing matches — a
344
+ successful read of nothing"*, which is the phrase for *we looked and there was nothing there*,
345
+ printed after a search that never opened the folder.
346
+ - **Type any part of a name or a path.** Every file under the workspace root now matches, at any
347
+ depth, whether or not any record mentions it — `README.md`, a script, a CSV you dropped in
348
+ yesterday. `node_modules/`, `dist/`, `.git/` and the other junk directories are skipped, exactly as
349
+ the folder lens already skips them, so search and the file tree agree about what is in there.
350
+ - **Type a phrase that is *inside* a file.** Text files are searched by content too, and the result
351
+ names the file **and the line**, so you can see the match before you open anything.
352
+ - **Click a result and it opens in a document tab** — the same reader you already use, with `view` ·
353
+ `raw` · `edit` · `diff`. A file the reader cannot show (an image, a binary) is still listed, greyed,
354
+ and says so, instead of opening a tab onto a failure.
355
+ - **It works in every mode.** The search box was rendered in all seven and did something in exactly
356
+ one — including doing nothing in the library lens, the one screen that lists files.
357
+ - **It tells you what it did not look at.** The walk is bounded so a huge folder can never hang the
358
+ box, and any bound that actually bit is stated in the heading. `11 of 1424 searched` is a
359
+ different sentence from `BOUNDED — files beyond the walk limit were not looked at`, and you get
360
+ whichever one is true. Nothing leaves your machine, as ever.
361
+
336
362
  ## What's new in 0.26.0
337
363
 
338
364
  **Your sessions now know which initiative they belong to — whatever branch you are on — and each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@86ae610
2
- assembled: 2026-08-02
1
+ kernel-source: pmos@d333cd5
2
+ assembled: 2026-08-04
3
3
  mode: vendor
@@ -156,6 +156,28 @@ tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the
156
156
  so D08's never-log-headers discipline applies to what this hands back. Matching runs against
157
157
  the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
158
158
 
159
+ ## `search everything…` searches the folder (the file lens)
160
+ The search box shipped matching three derived lists — initiative slugs, paths **declared on** an
161
+ initiative record, and session names — so a file PMOS had never been told about could not be found
162
+ by typing its exact name, and the miss printed *"nothing matches — a successful read of nothing"*
163
+ after a search that had never opened the folder.
164
+
165
+ `GET /api/search/files?q=…&ws=…&limit=…` is the folder corpus: every file under the workspace root,
166
+ matched by **path** and by **content**, with the matching line and its number. Clicking a hit opens
167
+ it in the ordinary document tab. It is a **different corpus** from `/api/search` above — that one is
168
+ scrollback, this one is disk — and they are deliberately separate endpoints, because their hits
169
+ share no field and one payload would have to lie about what it looked at.
170
+
171
+ Three guarantees. The walk uses the **folder lens's own rules** (`node_modules`, `dist`, `.git` and
172
+ the rest skipped, dotted names excluded), so search and the file tree can never disagree about what
173
+ is in the folder. There is **no path parameter** — the query is a needle, not a location — and the
174
+ walk starts at the root and never follows a symlink out of it, so nothing becomes reachable that
175
+ `/api/doc` would refuse; `openable` on each hit reports whether the reader can actually show it.
176
+ And it is **bounded, out loud**: `truncated` means files were never looked at, `content_truncated`
177
+ means every file was named but not every body was read, `scanned`/`content_scanned` give the counts.
178
+ Two caps, two flags — merged into one, the serious flag would light on any repository with a lock
179
+ file in it, which is the same as retiring it.
180
+
159
181
  ## Branches: a new one doesn't move the others
160
182
 
161
183
  Starting a session, the branch control is **optional** — the default is "stay on current" and it
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
- /* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9).
2
+ /* drive-guard — the driver's ONLY write path (pmos-autonomous-drive-build, R9;
3
+ * hardened from a denylist to an allowlist by `guard-and-ledger-hardening`, 2026-08-04).
3
4
  *
4
5
  * THE PROBLEM THIS EXISTS FOR, stated plainly because it is easy to under-rate:
5
6
  * nothing in PMOS checks an identity. `/api/gate` sets `pm: true` unconditionally
@@ -20,10 +21,44 @@
20
21
  * that throws, and the tests ATTEMPT each forbidden write and assert the throw. A rule the driver
21
22
  * is trusted to follow is exactly what leg A showed cannot be trusted.
22
23
  *
23
- * SCOPE, honestly. This binds the DRIVER, not the repo: a human, another agent, or a careless
24
- * `fs.writeFileSync` elsewhere is unaffected. It is a seatbelt on one vehicle, not a law of
24
+ * ── WHY THIS IS NOW AN ALLOWLIST ────────────────────────────────────────────────────────────────
25
+ * The first version was a denylist, and its suite DID attempt each forbidden write and assert the
26
+ * throw — six attempts, six assertions, exactly as the paragraph above claims. It still shipped
27
+ * SEVEN bypasses, each measured by an independent pass
28
+ * (planning/explainers/work-item-entity-surface-explainer.md) by running it against this module:
29
+ *
30
+ * 1. `PM_approved` nested 11 deep — recursion capped at `depth > 8`
31
+ * 2. `PM_approved: "true"` or `: 1` — strict `v === true`
32
+ * 3. the payload JSON.stringify'd — the `"` defeated /PM_approved\s*[:=]\s*true/
33
+ * 4. `status: 'PROMOTE'` / `'KILL'` — the regex required -D / -ED
34
+ * 5. `payload.frontmatter.status` — the structured status check was top-level only
35
+ * 6. `assertWritable({})` — FAILED OPEN: no file, no payload, no text → returned true
36
+ * 7. `parent_kr`, an evals verdict,
37
+ * `DECISIONS.md` — never named at all
38
+ *
39
+ * Plus a FALSE POSITIVE the other way: a legitimate initiative record was refused because its
40
+ * `status:` prose contained the word "promoted".
41
+ *
42
+ * The suite probed THE LIST. Nothing probed THE SHAPE OF THE LIST — and a denylist's shape is
43
+ * "everything somebody thought of". So:
44
+ *
45
+ * - sinks are ALLOWLISTED, closing (7) as a class rather than as three strings;
46
+ * - the content guard reads the SERIALIZED request, closing (1), (2) and (3) together — depth,
47
+ * type-looseness and stringification all vanish when you look at the text the write would
48
+ * actually produce;
49
+ * - an unreadable request THROWS, closing (6), the worst of the seven;
50
+ * - the disposition match is widened (4), recurses (5), and is anchored to the status value's
51
+ * LEADING TOKEN, so a real disposition is refused and prose that merely says "promoted" is not.
52
+ *
53
+ * SCOPE, honestly, and unchanged: this binds the DRIVER, not the repo. A human, another agent, or a
54
+ * careless `fs.writeFileSync` elsewhere is unaffected. It is a seatbelt on one vehicle, not a law of
25
55
  * physics — and that limit is the argument for making O5's boolean falsifiable at the engine, which
26
56
  * is a separate initiative this one does not pretend to have done.
57
+ *
58
+ * AND THE LIMIT WORTH SAYING OUT LOUD: **nothing imports this module** — only workspace/test.js.
59
+ * The live acceptance writer at `POST /api/gate` appends directly and never calls it. This hardening
60
+ * makes the boundary correct; it does not make it load-bearing. Its whole value is that when
61
+ * something does adopt it, it will not adopt seven bypasses with it.
27
62
  */
28
63
 
29
64
  const path = require('path');
@@ -36,53 +71,150 @@ class ForbiddenWrite extends Error {
36
71
  }
37
72
  }
38
73
 
39
- /** Ledgers that record a human decision. The driver may never append to these. */
40
- const FORBIDDEN_FILES = Object.freeze(['acceptances.jsonl']);
74
+ /**
75
+ * The sinks the driver's own free-zone policy sanctions (pmos-autonomous-drive, round 2): research,
76
+ * routing, the surface explainer, a disposable prototype, and DRAFT specs. Anything not here is
77
+ * refused — including every ledger, decision log and record nobody thought to enumerate.
78
+ *
79
+ * Directory prefixes rather than basenames, because these artifact classes name their files per
80
+ * initiative. `planning/evals/` admits a draft rubric and NOT `evals.jsonl`, which lives in
81
+ * `planning/runs/` and is refused with a reason below.
82
+ */
83
+ const ALLOWED_DIR_PREFIXES = Object.freeze([
84
+ 'planning/prd/',
85
+ 'planning/evals/',
86
+ 'planning/explainers/',
87
+ 'planning/research/',
88
+ 'planning/briefs/',
89
+ 'prototype/',
90
+ ]);
91
+
92
+ /**
93
+ * Machine ledgers the driver legitimately appends to: its own run record, the verification it owes,
94
+ * the work-items it declares, and the friction it hits.
95
+ *
96
+ * These are BASENAMES rather than a `planning/runs/` prefix, deliberately. Allowing the directory
97
+ * would admit `acceptances.jsonl`, `evals.jsonl` and `outcome_checks.jsonl` by default, and would
98
+ * admit the FOURTH ledger somebody adds next year without anyone deciding to — which is exactly the
99
+ * class-failure this rewrite exists to close. A new ledger is refused until it is named here.
100
+ *
101
+ * `agent-runs.jsonl` is on this list even though it carries `pre_run_contract`: the driver logging
102
+ * its own run is the free zone working as designed, and a fabricated `PM_approved` INSIDE that write
103
+ * is caught by the content guard below, not by refusing the file.
104
+ */
105
+ const ALLOWED_BASENAMES = Object.freeze([
106
+ 'agent-runs.jsonl',
107
+ 'verify-queue.jsonl',
108
+ 'work-items.jsonl',
109
+ 'discoveries.md',
110
+ ]);
111
+
112
+ /** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
113
+ * would otherwise be caught by the allowlist anyway — the value here is the message. */
114
+ const HUMAN_DECISION_SINKS = Object.freeze({
115
+ 'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
116
+ 'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
117
+ 'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
118
+ 'DECISIONS.md': 'an architectural decision is the PM\'s (D45)',
119
+ 'pm-decisions.md': 'the PM\'s decision journal is the PM\'s',
120
+ });
121
+
122
+ /** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
123
+ * status VALUE that begins with one is a disposition while prose mentioning one is not. */
124
+ /* Explicit groups, not a trailing `?` on the last letter. The first attempt at this widening wrote
125
+ * `KILLED?`, which is "KILLE" plus an optional "D" and therefore does NOT match the bare `KILL` —
126
+ * reintroducing bypass 4 while appearing to fix it. Caught only because every bypass is attempted
127
+ * individually, which is the whole argument for the suite below. */
128
+ const DISPOSITION_RE = /^\s*"?\s*(PROMOTE(D)?|ITERATE(D)?|KILL(ED)?)\b/i;
41
129
 
42
- /** Disposition tokens (D60). Case-insensitive; a status carrying one is a PM act. */
43
- const DISPOSITION_RE = /\b(PROMOTED|ITERATE[D]?|KILLED)\b/i;
130
+ /** Truthy-tolerant: an approval the PM never gave is what is being refused; its JSON type is not the
131
+ * point. Closes bypass 2. */
132
+ const TRUTHY = new Set([true, 1, 'true', 'TRUE', 'True', 'yes', 'YES', '1']);
44
133
 
45
- /** True when `value` contains a truthy PM_approved anywhere in its object graph. */
46
- function carriesApproval(value, depth = 0) {
47
- if (depth > 8 || value === null || typeof value !== 'object') return false;
134
+ const isObj = (v) => !!v && typeof v === 'object';
135
+
136
+ /** True when `value` carries a truthy PM_approved anywhere. No depth cap (bypass 1); cycle-safe. */
137
+ function carriesApproval(value, seen) {
138
+ const s = seen || new Set();
139
+ if (!isObj(value) || s.has(value)) return false;
140
+ s.add(value);
141
+ for (const [k, v] of Object.entries(value)) {
142
+ if (k === 'PM_approved' && TRUTHY.has(v)) return true;
143
+ if (carriesApproval(v, s)) return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ /** True when a `status`/`disposition` key ANYWHERE carries a leading disposition token (bypass 5). */
149
+ function carriesDisposition(value, seen) {
150
+ const s = seen || new Set();
151
+ if (!isObj(value) || s.has(value)) return false;
152
+ s.add(value);
48
153
  for (const [k, v] of Object.entries(value)) {
49
- if (k === 'PM_approved' && v === true) return true;
50
- if (k === 'pre_run_contract' && v && typeof v === 'object' && v.PM_approved === true) return true;
51
- if (carriesApproval(v, depth + 1)) return true;
154
+ if ((k === 'status' || k === 'disposition') && typeof v === 'string' && DISPOSITION_RE.test(v)) return true;
155
+ if (carriesDisposition(v, s)) return true;
52
156
  }
53
157
  return false;
54
158
  }
55
159
 
160
+ function onAllowlist(file) {
161
+ const norm = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
162
+ if (norm.includes('..')) return false; // no traversal out of an allowed directory
163
+ if (ALLOWED_BASENAMES.includes(path.basename(norm))) return true;
164
+ return ALLOWED_DIR_PREFIXES.some((p) => norm.startsWith(p));
165
+ }
166
+
56
167
  /**
57
168
  * The chokepoint. Every driver write goes through here or does not happen.
58
169
  * @param {{file:string, payload?:any, text?:string}} req
59
170
  * @throws {ForbiddenWrite}
60
171
  */
61
172
  function assertWritable(req) {
62
- const file = (req && req.file) || '';
63
- const base = path.basename(file);
173
+ // (6) FAIL CLOSED. The predecessor returned `true` here, which is the worst of the seven: a
174
+ // request this function cannot understand is precisely the one it must not wave through.
175
+ if (!isObj(req) || Array.isArray(req)) {
176
+ throw new ForbiddenWrite('an unreadable request', 'a write request must be an object — refused (fail-closed)');
177
+ }
178
+ if (typeof req.file !== 'string' || !req.file.trim()) {
179
+ throw new ForbiddenWrite('a request with no target', 'a write request must name its `file` — refused (fail-closed)');
180
+ }
64
181
 
65
- if (FORBIDDEN_FILES.includes(base)) {
66
- throw new ForbiddenWrite('an acceptance', 'the Acceptance Gate is the PM\'s and non-delegable (D12)');
182
+ const why = HUMAN_DECISION_SINKS[path.basename(req.file)];
183
+ if (why) throw new ForbiddenWrite(path.basename(req.file), why);
184
+
185
+ // (7) closed as a CLASS: anything not sanctioned is refused, named or not.
186
+ if (!onAllowlist(req.file)) {
187
+ throw new ForbiddenWrite(req.file,
188
+ `not on the driver's allowlist — it may write ${ALLOWED_DIR_PREFIXES.join(', ')} or ${ALLOWED_BASENAMES.join(', ')}`);
67
189
  }
68
- if (req && req.payload !== undefined && carriesApproval(req.payload)) {
69
- throw new ForbiddenWrite('pre_run_contract.PM_approved',
70
- 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
190
+
191
+ // (1)(2)(3) closed together by one look at the text the write would actually produce. A
192
+ // stringified payload, a "true", and an 11-deep nesting are all plainly visible here.
193
+ let serialized;
194
+ try {
195
+ serialized = JSON.stringify({ p: req.payload === undefined ? null : req.payload, t: req.text || '' });
196
+ } catch {
197
+ throw new ForbiddenWrite('an unserialisable payload',
198
+ 'a payload that cannot be inspected cannot be permitted — refused (fail-closed)');
71
199
  }
72
- if (req && typeof req.text === 'string' && /PM_approved\s*[:=]\s*true/i.test(req.text)) {
200
+ if (/PM_approved\W{0,4}(true|1|yes)/i.test(serialized) || carriesApproval(req.payload)) {
73
201
  throw new ForbiddenWrite('pre_run_contract.PM_approved',
74
- 'the contract is the PM\'s to approve before any build (D16) — caught in raw text');
202
+ 'the contract is the PM\'s to approve before any build (D16, AGENTS.md §4)');
75
203
  }
76
204
 
77
- // A disposition is a PM act (D60) wherever it lands: the record's `status:` is where it is
78
- // conventionally written, so both the structured and the raw-text shapes are refused.
79
- const status = req && req.payload && typeof req.payload === 'object' ? req.payload.status : null;
80
- if (typeof status === 'string' && DISPOSITION_RE.test(status)) {
205
+ // (4)(5) and the false positive: matched on a status VALUE's leading token, structured or raw, so
206
+ // `status: "PROMOTED by PM wawan"` is refused and `status: "derived from the promoted prototype"`
207
+ // is not. Prose that mentions a disposition is not a disposition.
208
+ if (carriesDisposition(req.payload)) {
81
209
  throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
82
210
  }
83
- if (req && typeof req.text === 'string' && /^status:.*$/im.test(req.text)
84
- && DISPOSITION_RE.test(req.text.match(/^status:.*$/im)[0])) {
85
- throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
211
+ if (typeof req.text === 'string') {
212
+ for (const line of req.text.split('\n')) {
213
+ const m = /^\s*(?:status|disposition)\s*:\s*(.*)$/i.exec(line);
214
+ if (m && DISPOSITION_RE.test(m[1])) {
215
+ throw new ForbiddenWrite('a disposition', 'promote/iterate/kill is the PM\'s (D60 step 7)');
216
+ }
217
+ }
86
218
  }
87
219
  return true;
88
220
  }
@@ -95,4 +227,7 @@ function guardedWriter(write) {
95
227
  };
96
228
  }
97
229
 
98
- module.exports = { assertWritable, guardedWriter, carriesApproval, ForbiddenWrite, FORBIDDEN_FILES };
230
+ module.exports = {
231
+ assertWritable, guardedWriter, carriesApproval, carriesDisposition, ForbiddenWrite,
232
+ ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
233
+ };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.26.0",
9
+ "version": "0.28.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1093,6 +1093,23 @@ export async function readDoc(path) {
1093
1093
  return res.text();
1094
1094
  }
1095
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
+
1096
1113
  /**
1097
1114
  * tab-doc.js — `saveDoc({ws, path, base, text})`. Refuse-on-stale: the server compares `base`
1098
1115
  * against the file on disk and REFUSES rather than overwriting a file that changed underneath.
@@ -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
- 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',
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
- 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 || '')]);
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
- 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)) {
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
  }
@@ -782,6 +782,153 @@ 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 truncated = false;
834
+ (function walk(dir, rel, depth) {
835
+ if (truncated) return;
836
+ let ents = [];
837
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
838
+ for (const e of ents) {
839
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
840
+ const full = path.join(dir, e.name);
841
+ const r = rel ? rel + '/' + e.name : e.name;
842
+ if (e.isDirectory()) {
843
+ // Depth is a bound like any other: reaching it means files below were NOT looked at.
844
+ if (depth < SEARCH_MAX_DEPTH) walk(full, r, depth + 1);
845
+ else truncated = true;
846
+ } else if (e.isFile()) {
847
+ if (files.length >= SEARCH_MAX_FILES) { truncated = true; return; }
848
+ files.push({ path: r, name: e.name, dir: rel, kind: preview.previewKind(e.name) });
849
+ }
850
+ }
851
+ })(root, '', 0);
852
+ const entry = { at: now, files, truncated, scanned: files.length };
853
+ searchIndexCache.set(root, entry);
854
+ return entry;
855
+ }
856
+
857
+ /** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
858
+ function searchSnippet(line, at) {
859
+ const s = String(line || '').replace(/\t/g, ' ');
860
+ if (s.length <= SEARCH_SNIPPET) return s.trim();
861
+ const start = Math.max(0, at - Math.floor(SEARCH_SNIPPET / 3));
862
+ return (start > 0 ? '…' : '') + s.slice(start, start + SEARCH_SNIPPET).trim()
863
+ + (start + SEARCH_SNIPPET < s.length ? '…' : '');
864
+ }
865
+
866
+ /**
867
+ * Search one workspace. Path matches first (they are what the PM typed a name for), then content
868
+ * matches over the readable text files that did not already match by path.
869
+ * `{ ok, q, hits, truncated, content_truncated, scanned, content_scanned, limit }` — never throws,
870
+ * never 404s: an empty `hits` with `truncated:false` IS the honest "looked, found nothing".
871
+ */
872
+ function searchWorkspace(root, query, limit) {
873
+ const q = String(query == null ? '' : query).trim();
874
+ const cap = Math.max(1, Math.min(SEARCH_LIMIT_MAX, parseInt(limit, 10) || SEARCH_LIMIT_DEFAULT));
875
+ if (!q) {
876
+ return { ok: true, q: '', hits: [], truncated: false, content_truncated: false,
877
+ scanned: 0, content_scanned: 0, limit: cap };
878
+ }
879
+ const needle = q.toLowerCase();
880
+ const idx = searchIndex(root);
881
+ const hits = [];
882
+ const seen = new Set();
883
+ let overflow = false; // more matched than `cap` — a REAL cut, not a coincidence
884
+
885
+ for (const f of idx.files) {
886
+ if (!f.path.toLowerCase().includes(needle)) continue;
887
+ seen.add(f.path);
888
+ if (hits.length >= cap) { overflow = true; break; }
889
+ hits.push({ ...f, match: 'path', line: null, snippet: null, openable: SEARCH_OPENABLE.has(f.kind) });
890
+ }
891
+
892
+ let read = 0;
893
+ let contentCut = false;
894
+ if (!overflow) {
895
+ for (const f of idx.files) {
896
+ if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
897
+ if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
898
+ const full = path.join(root, f.path);
899
+ let st;
900
+ try { st = fs.statSync(full); } catch { continue; }
901
+ if (!st.isFile()) continue;
902
+ if (st.size > SEARCH_CONTENT_BYTES) { contentCut = true; continue; }
903
+ read += 1;
904
+ let text;
905
+ try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
906
+ if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary masquerading as text — never scanned
907
+ const at = text.toLowerCase().indexOf(needle);
908
+ if (at === -1) continue;
909
+ if (hits.length >= cap) { overflow = true; break; }
910
+ const before = text.slice(0, at);
911
+ const lineNo = before.split('\n').length;
912
+ const lineStart = before.lastIndexOf('\n') + 1;
913
+ const lineEnd = text.indexOf('\n', at);
914
+ hits.push({
915
+ ...f, match: 'content', line: lineNo,
916
+ snippet: searchSnippet(text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd), at - lineStart),
917
+ openable: true,
918
+ });
919
+ }
920
+ }
921
+
922
+ return {
923
+ ok: true, q, hits, limit: cap,
924
+ // "files you searched for were never looked at" — the serious one.
925
+ truncated: idx.truncated || overflow,
926
+ // "every file was named; not every body was read" — the ordinary one.
927
+ content_truncated: contentCut,
928
+ scanned: idx.scanned, content_scanned: read,
929
+ };
930
+ }
931
+
785
932
  // Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
786
933
  // per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
787
934
  // non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
@@ -1136,10 +1283,32 @@ function deriveState(ev) {
1136
1283
  // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
1137
1284
  // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
1138
1285
  function acceptanceFilesFor(w) {
1286
+ return ledgerFilesFor(w, 'acceptances.jsonl');
1287
+ }
1288
+ // The general form of the comment above, because the defect it describes was never specific to
1289
+ // acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
1290
+ // state dir alone, so the supervision surface under-reported everything pmos-self records in
1291
+ // planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
1292
+ // One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
1293
+ function ledgerFilesFor(w, basename) {
1139
1294
  const out = [];
1140
1295
  const d = stateDirFor(w);
1141
- if (d) out.push(path.join(d, 'acceptances.jsonl'));
1142
- if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
1296
+ if (d) out.push(path.join(d, basename));
1297
+ if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', basename));
1298
+ return out;
1299
+ }
1300
+ // readJsonl over BOTH ledger locations, deduped by nothing: callers fold, and a row present in both
1301
+ // is the same row recorded twice, which every existing fold already tolerates.
1302
+ function readLedger(w, basename) {
1303
+ const out = [];
1304
+ for (const f of ledgerFilesFor(w, basename)) {
1305
+ let text = '';
1306
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
1307
+ for (const line of text.split('\n')) {
1308
+ if (!line.trim()) continue;
1309
+ try { out.push(JSON.parse(line)); } catch { /* a torn line is reported by the checkers, not here */ }
1310
+ }
1311
+ }
1143
1312
  return out;
1144
1313
  }
1145
1314
  function readAcceptances(w) {
@@ -2794,18 +2963,20 @@ function deriveNeeds(w) {
2794
2963
  if (!dir) return [];
2795
2964
  const needs = [];
2796
2965
  const settled = new Set();
2797
- for (const a of readJsonl(dir, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
2798
- for (const e of readJsonl(dir, 'evals.jsonl')) {
2966
+ for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
2967
+ for (const e of readLedger(w, 'evals.jsonl')) {
2799
2968
  if (!e.run_id || settled.has(e.run_id)) continue;
2800
2969
  const verdict = e.verdict ? String(e.verdict) : 'no verdict';
2801
2970
  needs.push({
2802
2971
  run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
2803
2972
  question: `Acceptance Gate open — evaluator says ${verdict}${e.score != null ? ` (${e.score})` : ''}`,
2804
- doc: e.rubric_path || null,
2973
+ // `rubric` is the deprecated alias every one of the 26 legacy rows uses; scripts/record-eval.js
2974
+ // requires `rubric_path` going forward. Read both, write only the new one.
2975
+ doc: e.rubric_path || e.rubric || null,
2805
2976
  });
2806
2977
  }
2807
2978
  const disposed = new Set();
2808
- for (const c of readJsonl(dir, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
2979
+ for (const c of readLedger(w, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
2809
2980
  const today = nowISO().slice(0, 10);
2810
2981
  let files = [];
2811
2982
  try { files = fs.readdirSync(path.join(dir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch {}
@@ -4309,6 +4480,25 @@ async function handleRequest(req, res) {
4309
4480
  });
4310
4481
  return json(res, built, built.ok ? 200 : 400);
4311
4482
  }
4483
+ // ── TWO CORPORA, TWO ENDPOINTS, ONE BOX ────────────────────────────────────────────────────
4484
+ // `/api/search` is EPISODIC — what a past session printed. `/api/search/files` is the FOLDER —
4485
+ // what is on disk under this workspace's root. They are deliberately not merged: the hit shapes
4486
+ // share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
4487
+ // search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
4488
+ // Collapsing them into one payload would mean one of the two lying about what it looked at.
4489
+ if (p === '/api/search/files') {
4490
+ // `search everything…`, finally over everything: every file under THIS workspace's root, by
4491
+ // path and by content. There is no path parameter — the query is a needle, not a location — so
4492
+ // this adds no reachable path; `openable` reports whether /api/doc (or /api/asset) could show a
4493
+ // hit. `truncated` + `scanned` are the contract that keeps a bounded answer from reading as a
4494
+ // complete one, which is the defect this endpoint exists to end rather than to relocate.
4495
+ const w = getWs(url.searchParams.get('ws'));
4496
+ if (!w) {
4497
+ return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
4498
+ hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
4499
+ }
4500
+ return json(res, { ws: w.id, ...searchWorkspace(w.root, url.searchParams.get('q'), url.searchParams.get('limit')) });
4501
+ }
4312
4502
  if (p === '/api/search') {
4313
4503
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
4314
4504
  // else here — an agent running in a pane can curl it to check what an earlier run tried.