create-pmos 0.16.0 → 0.17.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.
@@ -164,25 +164,88 @@ function git(root, args) {
164
164
  * evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
165
165
  */
166
166
  function commitRange(root) {
167
+ return buildRange(root, { spec: null });
168
+ }
169
+
170
+ /*
171
+ * The BOARD's range: the whole trunk, not the pull request.
172
+ *
173
+ * These are two different questions and they were sharing an answer. The gate asks "inside this
174
+ * diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
175
+ * this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
176
+ * the range is EMPTY, so every initiative reported `no implementation commits in this range` and
177
+ * collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
178
+ *
179
+ * Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
180
+ * That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
181
+ */
182
+ function historyRange(root) {
183
+ const trunk = ['origin/main', 'main', 'origin/master', 'master']
184
+ .find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
185
+ return buildRange(root, { spec: trunk || 'HEAD', whole: true });
186
+ }
187
+
188
+ /*
189
+ * ONE pass over the range, not one git call per question.
190
+ *
191
+ * Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
192
+ * (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
193
+ * shelled out once per path per initiative and buildCommits ran `git show` once per commit per
194
+ * initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
195
+ * on a full one, which is what made full history look impossible rather than merely unimplemented.
196
+ */
197
+ function buildRange(root, { spec, whole }) {
167
198
  if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
168
199
  return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
169
200
  }
170
- const candidates = [];
171
- if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
172
- candidates.push('origin/main', 'main', 'origin/master', 'master');
173
201
  let base = null;
174
- for (const c of candidates) {
175
- if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
176
- const mb = git(root, ['merge-base', c, 'HEAD']);
177
- if (mb && mb.trim()) { base = mb.trim(); break; }
202
+ let range = spec;
203
+ if (!range) {
204
+ const candidates = [];
205
+ if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
206
+ candidates.push('origin/main', 'main', 'origin/master', 'master');
207
+ for (const c of candidates) {
208
+ if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
209
+ const mb = git(root, ['merge-base', c, 'HEAD']);
210
+ if (mb && mb.trim()) { base = mb.trim(); break; }
211
+ }
212
+ range = base ? `${base}..HEAD` : 'HEAD';
178
213
  }
179
- const spec = base ? `${base}..HEAD` : 'HEAD';
180
- const log = git(root, ['log', '--reverse', '--format=%H', spec]);
214
+ const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct', '--name-only', range]);
181
215
  if (log == null) return { ok: false, reason: 'git history unreadable' };
182
- const shas = log.split('\n').map((s) => s.trim()).filter(Boolean);
216
+
217
+ const shas = [];
218
+ const changed = []; // index -> string[] of paths touched
219
+ const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
220
+ for (const chunk of log.split('\x1e')) {
221
+ if (!chunk.trim()) continue;
222
+ const lines = chunk.split('\n');
223
+ const head = (lines.shift() || '').trim();
224
+ const [sha, ct] = head.split(/\s+/);
225
+ if (!sha) continue;
226
+ shas.push(sha);
227
+ dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
228
+ changed.push(lines.map((s) => s.trim()).filter(Boolean));
229
+ }
183
230
  const index = new Map();
184
231
  shas.forEach((sha, i) => index.set(sha, i));
185
- return { ok: true, base, index, shas, wholeHistory: !base };
232
+
233
+ // Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
234
+ const added = new Map();
235
+ const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
236
+ if (addLog != null) {
237
+ for (const chunk of addLog.split('\x1e')) {
238
+ if (!chunk.trim()) continue;
239
+ const lines = chunk.split('\n');
240
+ const sha = (lines.shift() || '').trim();
241
+ const at = index.has(sha) ? index.get(sha) : null;
242
+ if (at == null) continue;
243
+ for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
244
+ if (!added.has(f)) added.set(f, at);
245
+ }
246
+ }
247
+ }
248
+ return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
186
249
  }
187
250
 
188
251
  /*
@@ -200,6 +263,10 @@ function commitRange(root) {
200
263
  function firstAppearance(root, range, rel) {
201
264
  const onDisk = fs.existsSync(path.join(root, rel));
202
265
  if (!range.ok) return onDisk ? undefined : null;
266
+ // The single-pass index answers first, and over the whole trunk it answers everything that has
267
+ // ever been committed — so the fallback below only runs for a PR range, where "added before the
268
+ // range" and "never committed" are genuinely different answers and only git can tell them apart.
269
+ if (range.added && range.added.has(rel)) return range.added.get(rel);
203
270
  const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
204
271
  const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
205
272
  if (!sha) return onDisk ? Infinity : null;
@@ -213,9 +280,10 @@ function buildCommits(root, range, lane) {
213
280
  if (!range.ok) return { unknown: true };
214
281
  let first = null; let count = 0; let sample = null;
215
282
  for (let i = 0; i < range.shas.length; i++) {
216
- const files = git(root, ['show', '--name-only', '--format=', range.shas[i]]);
217
- if (!files) continue;
218
- const hit = files.split('\n').map((s) => s.trim()).filter(Boolean).find((f) => isBuildPath(f, lane));
283
+ // range.changed came from the range's own single pass; the per-commit `git show` this replaced
284
+ // ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
285
+ const files = (range.changed && range.changed[i]) || [];
286
+ const hit = files.find((f) => isBuildPath(f, lane));
219
287
  if (!hit) continue;
220
288
  count++;
221
289
  if (first === null) { first = i; sample = hit; }
@@ -290,10 +358,29 @@ function computeWorkflow(id, opts) {
290
358
  const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
291
359
  const range = (opts && opts.range) || commitRange(root);
292
360
  // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
361
+ //
362
+ // `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
363
+ // MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
364
+ // would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
365
+ // -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
293
366
  const recIdx = firstAppearance(root, range, recRel);
294
- const legacy = (recIdx === undefined || recIdx === null)
295
- ? String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM // fallback only — see ENFORCED_FROM
296
- : recIdx === -1;
367
+ const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
368
+ /*
369
+ * `reconstructed_from` — the merge date of work whose record was written afterwards.
370
+ *
371
+ * A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
372
+ * test would enforce today's rules on work that predates them and report a violation nobody
373
+ * could ever have avoided. The declared merge date decides instead.
374
+ *
375
+ * This is not an escape hatch: it must name a date, that date is checkable against the merge it
376
+ * claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
377
+ * genuinely skipped the loop still reports blocked, which is the point of writing it down.
378
+ */
379
+ const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
380
+ const legacy = recIdx === -1 ? true
381
+ : /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
382
+ : recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
383
+ : String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
297
384
  /*
298
385
  * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
299
386
  * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
@@ -304,7 +391,12 @@ function computeWorkflow(id, opts) {
304
391
  * `lastActivity` is the honest fallback for everything else, because "when was this last worked
305
392
  * on" is a fact the repo already holds and nobody has to maintain.
306
393
  */
307
- const inChange = recIdx !== -1 && recIdx !== null && recIdx !== undefined;
394
+ // `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
395
+ // that is true of everything, which makes the marker noise — so the board passes the GATE's range
396
+ // separately and the marker is answered from that, or dropped when there is no such range.
397
+ const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
398
+ const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
399
+ const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
308
400
  const lastActivity = (() => {
309
401
  const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
310
402
  const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
@@ -522,10 +614,14 @@ function computeWorkflow(id, opts) {
522
614
  steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
523
615
  } else {
524
616
  const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
525
- steps.push(step('spec', 'spec', 'blocked',
617
+ // `legacy` applies here exactly as it does to O4. It always should have — the rule simply
618
+ // never fired for a legacy record while the board shared the gate's PR range, because an
619
+ // empty range has no build commit to be out of order with. Reading the trunk exposed it:
620
+ // 81 records that predate enforcement were suddenly failing a rule written after them.
621
+ steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
526
622
  `ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
527
623
  'the rubric is the spec (D13). Land the spec artifacts in an earlier commit than the build, or split this PR so the spec lands first',
528
- { blocking: true, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
624
+ { blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
529
625
  }
530
626
  }
531
627
  }
@@ -776,6 +872,10 @@ function renderBoard(rows, opts) {
776
872
  out.push('');
777
873
  // Never a silent cap — a truncated list that does not say so reads as "that is everything".
778
874
  if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
875
+ // The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
876
+ // not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
877
+ // can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
878
+ out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
779
879
  out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
780
880
  out.push('');
781
881
  return out.join('\n');
@@ -871,8 +971,12 @@ function main(argv) {
871
971
  return 0;
872
972
  }
873
973
 
874
- const range = commitRange(root);
875
- const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range }));
974
+ // R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
975
+ // The PR range is still computed and passed alongside — it is what `● in this change` means, and
976
+ // it is the one thing the trunk range genuinely cannot answer.
977
+ const range = historyRange(root);
978
+ const changeRange = commitRange(root);
979
+ const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range, changeRange }));
876
980
  if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
877
981
  if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
878
982
  process.stdout.write(renderBoard(rows, { self, all }));
@@ -882,6 +986,9 @@ function main(argv) {
882
986
  module.exports = {
883
987
  computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
884
988
  parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
989
+ // the board's trunk range, exported so the suite can assert the split: the same record is legacy
990
+ // under BOTH ranges, and `in this change` is false under the trunk one.
991
+ historyRange,
885
992
  ENFORCED_FROM,
886
993
  };
887
994
 
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /* lib/server-discovery.js — WHICH workspace server is running, and on WHICH port.
3
+ *
4
+ * The launcher used to answer that question with a constant: port 7433, or whatever
5
+ * `PMOS_WS_PORT` said in THIS shell. That constant is why a second repo could not be added to a
6
+ * running workspace. The sequence the PM hit, end to end:
7
+ *
8
+ * 1. something already held 7433 (a wedged server, a crashed one whose socket lingered, or an
9
+ * unrelated program), so the server exited with EADDRINUSE and printed the advice
10
+ * `PMOS_WS_PORT=7500 npm start`;
11
+ * 2. the PM followed it, and the workspace came up on 7500 — the browser tab is on 7500;
12
+ * 3. `pmos` in the other repo, in a fresh shell with no `PMOS_WS_PORT`, looked at 7433 only,
13
+ * found nothing, and started a SECOND server there. The repo was registered — with the
14
+ * server nobody was looking at.
15
+ *
16
+ * Nothing in that sequence errors. That is the whole defect: two servers, each honest, and a repo
17
+ * that "never added" because it landed in the one that isn't on screen.
18
+ *
19
+ * So the port is DISCOVERED, never assumed. The candidate list is built from state the running
20
+ * server itself writes (`state/server-<port>.pid`, written on listen and removed on shutdown —
21
+ * server.js), which makes a server discoverable however it was started: `pmos`, `npm start`, or a
22
+ * hand-typed `PMOS_WS_PORT=7500 node server.js`.
23
+ *
24
+ * An explicit `PMOS_WS_PORT` is treated as a PIN, not a hint: it narrows the candidates to that one
25
+ * port and never widens them. The test suite depends on that isolation — a suite that could attach
26
+ * to the PM's real workspace would drive their live sessions.
27
+ *
28
+ * Everything here is pure, or reads the filesystem and nothing else: no sockets, no child
29
+ * processes. The I/O lives in the caller (bin/pmos.js, doctor.js), so the whole policy is testable
30
+ * in-process without binding a port.
31
+ */
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const DEFAULT_PORT = 7433;
36
+ // How far the launcher rolls forward when the default port is taken by something that is not a
37
+ // workspace server. Bounded on purpose: "start somewhere, anywhere" would scatter servers across
38
+ // the ephemeral range and make discovery a scan.
39
+ const FALLBACK_SPAN = 12;
40
+
41
+ // state/server-<port>.pid — the launcher AND the server agree on this name; it is the only place a
42
+ // port is recorded, so it is the only shape parsed here.
43
+ const PID_RE = /^server-(\d{2,5})\.pid$/;
44
+
45
+ const pidFileName = (port) => `server-${Number(port)}.pid`;
46
+
47
+ /**
48
+ * Every `state/server-<port>.pid` on disk, most recently written first. A record is returned even
49
+ * when its pid is unreadable — a file whose contents are gone still names a port worth probing,
50
+ * and treating it as absent is how a running server becomes invisible.
51
+ */
52
+ function pidRecords(stateDir) {
53
+ let names = [];
54
+ try { names = fs.readdirSync(stateDir); } catch { return []; }
55
+ const out = [];
56
+ for (const name of names) {
57
+ const m = PID_RE.exec(name);
58
+ if (!m) continue;
59
+ const file = path.join(stateDir, name);
60
+ let pid = null, mtime = 0;
61
+ try { pid = Number(String(fs.readFileSync(file, 'utf8')).trim()) || null; } catch {}
62
+ try { mtime = fs.statSync(file).mtimeMs; } catch {}
63
+ out.push({ port: Number(m[1]), pid, file, mtime });
64
+ }
65
+ out.sort((a, b) => b.mtime - a.mtime);
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * The ports to probe, in order. With a pinned port that list is exactly one long — see the header:
71
+ * a pin is an instruction, and widening it would let a test (or a second workspace deliberately
72
+ * kept apart) attach to a server it was told not to touch.
73
+ */
74
+ function candidatePorts({ stateDir, pinned = null, defaultPort = DEFAULT_PORT } = {}) {
75
+ const pin = Number(pinned);
76
+ if (Number.isInteger(pin) && pin > 0) return [pin];
77
+ const seen = new Set();
78
+ const out = [];
79
+ const push = (p) => { if (Number.isInteger(p) && p > 0 && !seen.has(p)) { seen.add(p); out.push(p); } };
80
+ push(Number(defaultPort) || DEFAULT_PORT);
81
+ for (const r of pidRecords(stateDir)) push(r.port);
82
+ return out;
83
+ }
84
+
85
+ /** Is this pid still around? EPERM means yes-but-not-ours, which is still alive. */
86
+ function pidAlive(pid, kill) {
87
+ const k = kill || ((p, sig) => process.kill(p, sig));
88
+ if (!Number.isInteger(pid) || pid <= 0) return false;
89
+ try { k(pid, 0); return true; } catch (e) { return !!e && e.code === 'EPERM'; }
90
+ }
91
+
92
+ /**
93
+ * Which pid files describe a server that is gone. Deliberately conservative: a file is stale only
94
+ * when its port does NOT answer AND its pid is NOT alive. A server that is mid-start — listening
95
+ * socket not yet open, process very much alive — must survive this, or one launcher would delete
96
+ * another's record and make it undiscoverable a second later.
97
+ */
98
+ function stalePidFiles(stateDir, { livePorts = [], alive = pidAlive } = {}) {
99
+ const live = new Set(livePorts.map(Number));
100
+ return pidRecords(stateDir)
101
+ .filter((r) => !live.has(r.port) && !alive(r.pid))
102
+ .map((r) => r.file);
103
+ }
104
+
105
+ /**
106
+ * The first port at or after `start` that `isFree` accepts, within the bounded span. `isFree` is
107
+ * injected (the caller owns the socket), so this stays pure and the span is assertable.
108
+ */
109
+ function nextFreePort(start, isFree, span = FALLBACK_SPAN) {
110
+ const from = Number(start) || DEFAULT_PORT;
111
+ for (let p = from; p < from + span; p++) if (isFree(p)) return p;
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Does this JSON body come from a workspace server? `/api/sessions` answers `{sessions:[…]}`, and
117
+ * requiring the array is what keeps an unrelated program that happens to serve JSON on 7433 from
118
+ * being adopted as "the workspace" — the launcher would then register the PM's repo into it.
119
+ */
120
+ function looksLikeWorkspace(body) {
121
+ return !!body && typeof body === 'object' && Array.isArray(body.sessions);
122
+ }
123
+
124
+ module.exports = {
125
+ DEFAULT_PORT, FALLBACK_SPAN, PID_RE,
126
+ pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, nextFreePort, looksLikeWorkspace,
127
+ };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.16.0",
9
+ "version": "0.17.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
@@ -29,7 +29,7 @@
29
29
  "playwright-core": "^1.61.1",
30
30
  "ws": "^8.18.0"
31
31
  },
32
- "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
32
+ "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -774,6 +774,34 @@ export async function addRepo({ source, kind }) {
774
774
  };
775
775
  }
776
776
 
777
+ /**
778
+ * panel-repos.js — `chooseFolder(start)`. The machine's OWN folder dialog, opened by the server.
779
+ *
780
+ * A browser cannot hand back an absolute path — its directory picker yields a name and a handle,
781
+ * never a path — so the add-a-product form could only ever offer a text field, and the PM had to
782
+ * know and type the path of the repo they were looking at. The server runs on the PM's machine and
783
+ * binds 127.0.0.1 (D62), so it opens the native chooser and returns the chosen path; this function
784
+ * only asks. Registration still goes through `addRepo()` → POST /api/workspaces, so nothing here
785
+ * bypasses a guard: the dialog produces a string, and the PM confirms it like any other.
786
+ *
787
+ * `{cancelled:true}` and `{unsupported:true}` are ANSWERS, not failures — a headless Linux box has
788
+ * no chooser at all, which is what `listFolder()` below stands in for.
789
+ */
790
+ export async function chooseFolder(start) {
791
+ if (FIXTURE) return { ok: false, unsupported: true };
792
+ return postJson('/api/pick-folder', { start: start || '' });
793
+ }
794
+
795
+ /**
796
+ * panel-repos.js — `listFolder(dir)`. The in-app folder browser's data source, for when there is
797
+ * no native chooser (and for anyone who prefers it). The server returns directory NAMES only —
798
+ * never a file, never a byte of content — and flags which ones are git repositories.
799
+ */
800
+ export async function listFolder(dir) {
801
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no filesystem' };
802
+ return getJson('/api/fs/list', dir ? { path: dir } : null);
803
+ }
804
+
777
805
  /**
778
806
  * panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
779
807
  *
@@ -299,6 +299,93 @@ function field(key, value) {
299
299
  });
300
300
  }
301
301
 
302
+ /* ── choosing the folder, instead of typing its path ─────────────────────────────────────────
303
+ * The form's local branch used to be a bare text field, so "add the repo I am looking at" meant
304
+ * knowing its absolute path and typing it correctly — and the two endpoints that exist to avoid
305
+ * exactly that (`/api/pick-folder`, `/api/fs/list`) had no caller anywhere in the front end.
306
+ *
307
+ * Two ways in, because one of them is not always there: the machine's native dialog first, and the
308
+ * in-app browser when there is no native chooser (headless Linux), when one is already open, or
309
+ * when the PM asks for it. A cancelled dialog changes nothing — it is an answer, not a failure.
310
+ */
311
+ const browseRowStyle = 'display:grid;grid-template-columns:76px 1fr auto;gap:10px;padding:10px 14px';
312
+ const chooseStyle = 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:32px;'
313
+ + 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground)';
314
+
315
+ /** The in-app browser, opened at `dir` (empty = the server's idea of home). */
316
+ function openBrowse(dir) {
317
+ // Fixture mode reads NOTHING off this machine — the comparator drives this surface, and a run
318
+ // that opened a dialog or listed the PM's home directory would be doing real work behind a
319
+ // scripted board. It says so instead of degrading into a vaguer "no endpoint" line.
320
+ if (FIXTURE) {
321
+ setState({ addLog: [{ t: 'the folder chooser is off in fixture mode — nothing on this machine is read', c: 'var(--warning)' }] });
322
+ return;
323
+ }
324
+ if (!dataMod || typeof dataMod.listFolder !== 'function') {
325
+ setState({ addLog: [{ t: 'no local endpoint for browsing folders — type the path instead', c: 'var(--warning)' }] });
326
+ return;
327
+ }
328
+ setState({ addBrowse: { loading: true, path: dir || '', entries: [] } });
329
+ Promise.resolve(dataMod.listFolder(dir)).then((res) => {
330
+ if (!res || res.ok === false) {
331
+ setState({ addBrowse: { path: dir || '', entries: [], error: (res && res.error) || 'that folder could not be listed' } });
332
+ return;
333
+ }
334
+ setState({ addBrowse: res });
335
+ }).catch((e) => setState({ addBrowse: { path: dir || '', entries: [], error: e && e.message ? e.message : String(e) } }));
336
+ }
337
+
338
+ /** Ask the server to open the machine's folder dialog; fall back to the in-app browser. */
339
+ function chooseFolder() {
340
+ if (FIXTURE || !dataMod || typeof dataMod.chooseFolder !== 'function') { openBrowse(state.addPath); return; }
341
+ setState({ addPicking: true });
342
+ Promise.resolve(dataMod.chooseFolder(state.addPath)).then((res) => {
343
+ setState({ addPicking: false });
344
+ if (res && res.ok && res.root) { setState({ addPath: res.root, addBrowse: null }); return; }
345
+ if (res && res.cancelled) return; // the PM closed the dialog: no change, no noise
346
+ openBrowse(state.addPath); // unsupported, busy, or unreachable → in-app
347
+ }).catch(() => { setState({ addPicking: false }); openBrowse(state.addPath); });
348
+ }
349
+
350
+ /** Take the folder currently open in the browser as the answer. */
351
+ function useFolder(p) {
352
+ if (!p) return;
353
+ setState({ addPath: p, addBrowse: null });
354
+ }
355
+
356
+ const browseRow = (label, title, onClick, muted) => el('button', {
357
+ onClick, title,
358
+ style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px;min-height:28px;text-align:left;'
359
+ + 'font-family:var(--mono);font-size:11px;color:var(--' + (muted ? 'muted-foreground' : 'foreground') + ')',
360
+ hover: 'background:var(--accent)',
361
+ }, label);
362
+
363
+ /** The browser body: where we are, one row per sub-directory, and a way to take the current one. */
364
+ function browseBody() {
365
+ const b = state.addBrowse;
366
+ const rows = [];
367
+ rows.push(el('div', { style: 'display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--border)' },
368
+ el('span', { style: 'flex:1;font-family:var(--mono);font-size:11px;overflow-wrap:anywhere' }, ip(b.path || '…')),
369
+ el('button', { onClick: () => useFolder(b.path), style: chooseStyle + ';min-height:26px', hover: 'border-color:var(--ring)' },
370
+ ip('use this folder')),
371
+ ));
372
+ if (b.error) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--destructive)' }, ip(b.error)));
373
+ else if (b.loading) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('reading…')));
374
+ else {
375
+ const list = [];
376
+ if (b.parent) list.push(browseRow('↑ ..', b.parent, () => openBrowse(b.parent), true));
377
+ for (const e of b.entries || []) {
378
+ // The repo marker is the whole point of showing this list rather than a plain file chooser:
379
+ // it says which of these folders PMOS can actually open as a workspace.
380
+ list.push(browseRow((e.isRepo ? '⑂ ' : '· ') + e.name, e.path, () => openBrowse(e.path), !e.isRepo));
381
+ }
382
+ if (b.denied) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--warning)' }, ip('this folder cannot be read')));
383
+ else if (!list.length) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('no sub-folders here')));
384
+ rows.push(el('div', { style: 'max-height:220px;overflow-y:auto' }, list));
385
+ }
386
+ return el('div', { style: 'margin:0 14px 10px;border:1px solid var(--border)' }, rows);
387
+ }
388
+
302
389
  function srcButton(id, label) {
303
390
  const on = state.addSrc === id;
304
391
  return el('button', {
@@ -425,10 +512,16 @@ export function addProductBody() {
425
512
  el('button', { onClick: ingestGithub, style: goStyle }, '⇣ connect + ingest'),
426
513
  ));
427
514
  } else {
428
- out.push(el('div', { style: rowStyle },
515
+ // Field FIRST, chooser beside it: the path is still typeable (and still the thing that gets
516
+ // registered), the button only fills it in. The row keeps label + input as its first two
517
+ // children, which is the shape panel 1's suite reads.
518
+ out.push(el('div', { style: browseRowStyle },
429
519
  el('span', { style: labelStyle }, 'folder'),
430
520
  field('addPath', state.addPath),
521
+ el('button', { onClick: chooseFolder, style: chooseStyle, hover: 'border-color:var(--ring)' },
522
+ ip(state.addPicking ? 'choosing…' : 'choose…')),
431
523
  ));
524
+ if (state.addBrowse) out.push(browseBody());
432
525
  out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
433
526
  'Starts with an empty board — nothing is imported from a bare folder. File the first initiative to begin.'));
434
527
  out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
@@ -28,6 +28,10 @@ function initial() {
28
28
  product: null, newSessProfile: 'claude-work', intgSynced: {},
29
29
  addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
30
30
  addLog: [], addedProducts: [], extraKnow: {},
31
+ // the folder chooser's two transient bits: the in-app browser's current listing (null = closed)
32
+ // and whether the machine's native dialog is open. Neither is persisted — a dialog that
33
+ // "reopens" on reload is a lie about what the OS is showing.
34
+ addBrowse: null, addPicking: false,
31
35
  newType: 'init', newName: '', newSlug: '', newAnchor: null, newFlow: 'adlc-feature',
32
36
  userGroups: {}, userKrs: {}, addedInits: {}, userInits: {}, selTask: null, collapsedPhases: {},
33
37
  detail: { kind: 'init' },