create-pmos 0.103.0 → 0.105.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.103.0",
3
+ "version": "0.105.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → 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@3d2d579
1
+ kernel-source: pmos@d4092b3
2
2
  assembled: 2026-10-08
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-08T00:46:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T01:07:38Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-08T00:46:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T01:07:38Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-08T00:46:10Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T01:07:38Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -30,7 +30,7 @@ Each place is drawn when its run is built. Until then the place says which run b
30
30
  Settings holds settings only, in eight sections. On a wide screen they are cards in two columns, like Agenda: Repositories, New sessions, Profiles and Theme on the left, and Validate consent, People, Online and Updates on the right. On a narrow screen the cards stack in one column. Each switch (Validate consent, People, Online) turns on when you press it. **Check for updates** is at the top right of the page.
31
31
 
32
32
  - **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
33
- - **Profiles**: the Claude profiles a session can start with. These are the descriptors in `~/.claude/profiles`, and also the profiles you already run: a shell command that sets `CLAUDE_CONFIG_DIR` (for example `alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'`), a launcher script named `claude-*`, or a config folder such as `~/.claude-work`. A profile found this way says where it was found, and a session under it sets only `CLAUDE_CONFIG_DIR`. Press **New profile** to make one. This writes `~/.claude/profiles/<id>.json`. Account secrets are never shown in the browser.
33
+ - **Profiles**: the Claude profiles a session can start with. These are the descriptors in `~/.claude/profiles`, and also the profiles you already run: a shell command that sets `CLAUDE_CONFIG_DIR` (for example `alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'`), a launcher script named `claude-*`, or a config folder such as `~/.claude-work`. Each profile is a row with a **Detected** badge when PMOS found it rather than wrote it, and a **Default** badge when it is the default. Its ⋯ menu has **Use as default**. A profile found this way says where it was found, and a session under it sets only `CLAUDE_CONFIG_DIR`. The list reads again every few seconds while Settings is open, and **Look again** in the Claude profiles menu reads it at once. When nothing is found, the note names the start-up files and folders PMOS looked at. Press **New profile** to make one. This writes `~/.claude/profiles/<id>.json`. Account secrets are never shown in the browser.
34
34
  - **New sessions**: the default CLI, the default model, the default Claude profile and the default launch flags for a new session. The flags come from `claude --help`, for example `--chrome`. A flag that needs a value lists the values it accepts.
35
35
  - **Validate consent**: the consent of the current repository. Use its switch to turn it on or off. **Open Validate** opens the Validate page as a tab.
36
36
  - **People**: whether the morning brief keeps attendee names from the calendar for the current repository. It is off until you turn it on. Turning it off also hides the names recorded before.
@@ -39,7 +39,7 @@ Settings holds settings only, in eight sections. On a wide screen they are cards
39
39
  - **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** at the top of the page to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, one button shows: **Update to** and the version. It updates every part that is older, in this order:
40
40
  - **The kit** in the current repository, when it is older. This does what `npx create-pmos@latest --update` does in a terminal. It changes only the kit's own files under `pmos/` (skills, scripts, templates and guides). Your records in `state/`, `planning/` and `okf/product/` do not change. The last lines of the output show under the button. The changed files are under `pmos/`, which Sync treats as records, so Sync (or automatic sync) sends them with your other records. To read them first, look at `git diff pmos/` before you sync.
41
41
  - **The workspace app**, when it is older. This does what `pmos upgrade` does in a terminal. The button asks you first. The workspace stops, installs the new version and starts again, and the page reloads when the new version starts. The reloaded page runs the new version's code: each start gives the page files a new address, so neither the browser nor the Online address's tunnel can keep the old ones. You do not need a terminal or a hard refresh. Sessions keep running in tmux. It works on the computer that runs the workspace and at its Online address, because the page comes back there after the restart. It also works on a phone or public address that signs in with a token set in `PMOS_TOKEN`, which the workspace keeps. When the workspace made its own token at start, the token changes on the restart, so the page refuses and says where to press it. When the workspace runs from a PMOS checkout, update it with `git pull` instead.
42
- - **Testing an update before it is published.** In a PMOS checkout, run `bash scripts/build-local-package.sh`. It builds the package that a publish would build, in `~/.pmos-local-package`, with a version newer than the published one, and prints the command that starts the workspace on it. That command sets `PMOS_UPDATE_SOURCE` to the package on the same computer: its folder, or the `.tgz` that `npm pack` makes. Then **Check for updates** reads that package's version and does not ask npm, and both updates install that package. The Updates card names the package it checks. A checkout may update the app this way. When the path is wrong, the card says why and does not fall back to npm. Unset the variable, or set it to `npm`, to use the registry again.
42
+ - **Testing an update before it is published.** In a PMOS checkout, run `bash scripts/build-local-package.sh`. It builds the package that a publish would build, in `~/.pmos-local-package`, with a version newer than the published one, and prints the command that starts the workspace on it. That command sets `PMOS_UPDATE_SOURCE` to the package on the same computer: its folder, or the `.tgz` that `npm pack` makes. Then **Check for updates** reads that package's version and does not ask npm, and both updates install that package. The Updates card names the package it checks, and the line under the buttons says "from the local package" after a check. There is no checkbox for this; the variable is the only switch. A checkout may update the app this way. When the path is wrong, the card says why and does not fall back to npm. Unset the variable, or set it to `npm`, to use the registry again.
43
43
 
44
44
  A new screen or a renamed menu comes with the workspace app, not with the kit.
45
45
 
@@ -166,20 +166,6 @@ async function latest({ exec = cp.execFile, source: src = source() } = {}) {
166
166
  return { ok: true, version: v };
167
167
  }
168
168
 
169
- /** Check the local repository's package version (for dev/testing without npm). Reads npm-pkg/package.json for kit. */
170
- function latestLocal({ repoRoot = null } = {}) {
171
- try {
172
- const pkgPath = path.join(String(repoRoot || ''), 'npm-pkg', 'package.json');
173
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
174
- if (!pkg.version || !validVersion(pkg.version)) {
175
- return { ok: false, error: 'Local package.json has no valid version' };
176
- }
177
- return { ok: true, version: pkg.version, source: 'local-git' };
178
- } catch (e) {
179
- return { ok: false, error: `Could not read local version: ${e.message}` };
180
- }
181
- }
182
-
183
169
  /**
184
170
  * npm lists a new version in the check a moment before its download is served everywhere. A download asked for in that
185
171
  * gap answers 404, and npm's CDN keeps the 404 for up to five minutes (Cache-Control max-age=300), so the same press
@@ -264,4 +250,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
264
250
  }
265
251
  }
266
252
 
267
- module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, latestLocal, updateKit, updateEnv, upgradeApp, whereFrom };
253
+ module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
@@ -31,7 +31,8 @@ const path = require('path');
31
31
  const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
32
32
  /** Something only Claude Code writes into its config directory. A folder with none of these is not offered. */
33
33
  const CONFIG_MARKERS = ['settings.json', '.claude.json', '.credentials.json', 'projects', 'CLAUDE.md', 'commands', 'agents', 'todos', 'statsig', 'shell-snapshots'];
34
- const RC_FILES = ['.zshrc', '.zprofile', '.zshenv', '.zlogin', '.bashrc', '.bash_profile', '.bash_login', '.profile', '.aliases', '.bash_aliases', '.config/fish/config.fish'];
34
+ const RC_FILES = ['.zshrc', '.zprofile', '.zshenv', '.zlogin', '.bashrc', '.bash_profile', '.bash_login', '.profile', '.aliases', '.bash_aliases', '.bash_functions', '.zshrc.local', '.zsh/aliases.zsh', '.zsh_aliases',
35
+ '.config/zsh/.zshrc', '.config/zsh/aliases.zsh', '.config/bash/aliases', '.config/shell/aliases', '.oh-my-zsh/custom/aliases.zsh', '.config/fish/config.fish'];
35
36
  const FISH_FUNCTIONS = '.config/fish/functions';
36
37
  const BIN_DIRS = ['.local/bin', 'bin', '.bin', '.config/claude/bin'];
37
38
  const MAX_TEXT = 256 * 1024; // a start-up file larger than this is not read
@@ -191,4 +192,29 @@ function detectProfiles(homeDir) {
191
192
  }));
192
193
  }
193
194
 
194
- module.exports = { detectProfiles, resolveDir, configDirOf, fromShellText, ID_RE };
195
+ /**
196
+ * searched(homeDir) → what detectProfiles looked at, for the surface to say when it found nothing: the start-up files that
197
+ * exist (and so were read), the launcher scripts named claude-*, and the `~/.claude-*` folders with whether each holds a file
198
+ * only Claude writes. Names only, under the home folder — no contents, no paths outside it.
199
+ */
200
+ function searched(homeDir) {
201
+ const out = { files: [], launchers: [], folders: [] };
202
+ if (typeof homeDir !== 'string' || !homeDir) return out;
203
+ for (const rel of RC_FILES) if (safeRead(path.join(homeDir, rel), MAX_TEXT) != null) out.files.push('~/' + rel);
204
+ for (const rel of BIN_DIRS) {
205
+ let names = [];
206
+ try { names = fs.readdirSync(path.join(homeDir, rel)); } catch { continue; }
207
+ for (const n of names.slice().sort()) if (/^claude[-_.]/.test(n)) out.launchers.push('~/' + rel + '/' + n);
208
+ }
209
+ for (const [parent, re] of [[homeDir, /^\.claude[-_.][A-Za-z0-9]/], [path.join(homeDir, '.config'), /^claude[-_][A-Za-z0-9]/]]) {
210
+ let names = [];
211
+ try { names = fs.readdirSync(parent); } catch { continue; }
212
+ for (const n of names.slice().sort()) {
213
+ const d = path.join(parent, n);
214
+ if (re.test(n) && isDir(d)) out.folders.push({ name: '~/' + path.relative(homeDir, d), claude: looksLikeConfigDir(d) });
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ module.exports = { detectProfiles, searched, resolveDir, configDirOf, fromShellText, ID_RE };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.103.0",
3
+ "version": "0.105.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.103.0",
9
+ "version": "0.105.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.103.0",
3
+ "version": "0.105.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",
@@ -173,30 +173,58 @@ function addBoard() {
173
173
  }
174
174
 
175
175
  function profilesSection() {
176
- const open = () => setState({ detail: { kind: 'prof' }, col2: true });
176
+ const open = (id) => setState({ detail: { kind: 'prof', id: id || null }, col2: true });
177
+ loadSessionReads();
178
+ const found = sess.profiles && Array.isArray(sess.profiles.profiles) ? sess.profiles.profiles : [];
179
+ const def = sessionDefaults().profile;
177
180
  // every row has its ⋯, here as on the repositories: on a phone it is the one control a row is known by
178
181
  const more = iconButton({
179
182
  icon: 'ellipsis', ariaLabel: P.more(P.profilesRow), attrs: { 'data-fr': 'profiles-more' },
180
- onClick: () => menu({ anchor: more, ariaLabel: P.more(P.profilesRow), items: [{ label: P.profilesOpen, onSelect: open }] }),
183
+ onClick: () => menu({ anchor: more, ariaLabel: P.more(P.profilesRow), items: [
184
+ { label: P.profilesOpen, onSelect: () => open(found[0] && found[0].id) },
185
+ { label: P.profilesLookAgain, onSelect: () => readProfilesAgain() },
186
+ ] }),
181
187
  });
182
- loadSessionReads();
183
- const found = sess.profiles && Array.isArray(sess.profiles.profiles) ? sess.profiles.profiles : [];
184
188
  const kids = [row({
185
- title: P.profilesRow, meta: [P.profilesMeta], onOpen: open, openLabel: P.profilesOpen, trail: [more],
189
+ title: P.profilesRow, meta: [P.profilesMeta], onOpen: () => open(found[0] && found[0].id), openLabel: P.profilesOpen, trail: [more],
186
190
  attrs: { 'data-fr-row': 'profiles' },
187
191
  })];
192
+ // one row per profile the reader returned, descriptors and detected alike: the list the default select and the New session form offer
193
+ for (const x of found) {
194
+ const isDef = def === x.id;
195
+ const rm = iconButton({
196
+ icon: 'ellipsis', ariaLabel: P.more(x.id), attrs: { 'data-fr': 'profile-more' },
197
+ onClick: () => menu({ anchor: rm, ariaLabel: P.more(x.id), items: [
198
+ { label: isDef ? P.profileNoDefault : P.profileUseDefault, onSelect: () => chooseDefaultProfile(isDef ? null : x.id) },
199
+ { label: P.profilesOpen, onSelect: () => open(x.id) },
200
+ ] }),
201
+ });
202
+ kids.push(row({
203
+ title: x.id, meta: [P.profileMeta(x)],
204
+ signals: [isDef ? badge({ text: P.profileDefaultBadge, tone: 'outline' }) : null, x.detected ? badge({ text: P.profileDetectedBadge, tone: 'outline' }) : null].filter(Boolean),
205
+ onOpen: () => open(x.id), openLabel: P.profilesOpen + ': ' + x.id, trail: [rm],
206
+ attrs: { 'data-fr-row': 'profile', 'data-profile': x.id, 'data-default': isDef ? 'true' : 'false', 'data-detected': x.detected ? 'true' : 'false' },
207
+ }));
208
+ }
188
209
  if (sess.formOpen) kids.push(profileForm());
189
210
  if (sess.msg) kids.push(el('p', { class: 'fr-set__note', role: 'status', 'data-fr': 'profiles-msg' }, sess.msg));
190
211
  kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'profiles-found' }, !sess.profiles ? P.profilesReading
191
212
  : sess.profiles.ok === false ? P.profilesUnread(sess.profiles.error)
192
- : found.length ? P.profilesFound(found.map((x) => x.id), found.filter((x) => x.detected).map((x) => x.id)) : P.profilesNone(sess.profiles.dir)));
213
+ : found.length ? P.profilesFound(found.map((x) => x.id), found.filter((x) => x.detected).map((x) => x.id)) : P.profilesNone(sess.profiles.dir, sess.profiles.searched)));
193
214
  const add = button({ label: P.profileNew, variant: 'outline', size: 'sm', icon: 'plus', attrs: { 'data-fr': 'profile-new', 'aria-expanded': sess.formOpen ? 'true' : 'false' },
194
215
  onClick: () => { sess.formOpen = !sess.formOpen; sess.msg = ''; setState({}); } });
195
216
  return section('profiles', P.profiles, kids, add, 'bot');
196
217
  }
197
218
 
219
+ /** The default Claude profile, from a row or the select: kept for the dock, and remembered by the server for the New session form. */
220
+ function chooseDefaultProfile(id) {
221
+ setSessionDefaults({ profile: id || null });
222
+ pmPost('/api/profiles/prefer', { agent: 'claude', profile: id || null });
223
+ setState({});
224
+ }
225
+
198
226
  /* ── the Claude profile and launch flags a new session starts with (workspace-launch-flags) ───────────────────────── */
199
- const sess = { profiles: null, flags: null, asked: false, flagsOpen: false, filter: '', formOpen: false, msg: '' };
227
+ const sess = { readAt: 0, profiles: null, flags: null, asked: false, flagsOpen: false, filter: '', formOpen: false, msg: '' };
200
228
 
201
229
  /** Both reads once per page load; a created profile asks for the profile read again. */
202
230
  function loadSessionReads() {
@@ -205,10 +233,21 @@ function loadSessionReads() {
205
233
  sess.asked = true;
206
234
  pmGet('/api/claude-flags').then((j) => { sess.flags = j || { ok: false, error: 'no answer', flags: [] }; setState({}); });
207
235
  }
208
- if (!sess.profiles && !sess.reading) {
209
- sess.reading = true;
210
- pmGet('/api/profiles').then((j) => { sess.reading = false; sess.profiles = j || { ok: false, error: 'no answer', profiles: [] }; setState({}); });
211
- }
236
+ // read again when it is a while old, so a profile added since (a folder, an alias) shows without a reload
237
+ if (!sess.reading && (!sess.profiles || Date.now() - sess.readAt > 15000)) readProfilesAgain();
238
+ }
239
+
240
+ /** Ask for the profiles again. The list on screen stays until the answer comes. */
241
+ function readProfilesAgain() {
242
+ if (FIXTURE || sess.reading) return;
243
+ sess.reading = true;
244
+ pmGet('/api/profiles').then((j) => {
245
+ sess.reading = false; sess.readAt = Date.now();
246
+ sess.profiles = j || { ok: false, error: 'no answer', profiles: [] };
247
+ // the detail column and the New session form read the same answer
248
+ state.profiles = sess.profiles;
249
+ setState({});
250
+ });
212
251
  }
213
252
 
214
253
  let profFormNode = null;
@@ -239,7 +278,7 @@ function profileForm() {
239
278
  mcp: mcpF.control.value.split(/[\s,]+/).filter(Boolean), env };
240
279
  const j = await pmPost('/api/profiles/create', body);
241
280
  if (!j || j.ok === false) { err.textContent = (j && j.error) || P.pfFailed; return; }
242
- reset(); sess.profiles = null; sess.msg = P.pfCreated(j.profile.id, j.profile.env_count);
281
+ reset(); sess.profiles = null; sess.readAt = 0; sess.msg = P.pfCreated(j.profile.id, j.profile.env_count);
243
282
  setState({});
244
283
  };
245
284
  profFormNode = el('form', { class: 'fr-set__form', 'data-fr': 'profile-form', onSubmit: (e) => { e.preventDefault(); submit(); } },
@@ -277,12 +316,8 @@ function sessionsSection() {
277
316
  label: P.profileDefault, kind: 'select', value: d.profile || '',
278
317
  options: [{ value: '', label: P.noProfile }, ...(stale ? [{ value: d.profile, label: P.profileMissing(d.profile) }] : []),
279
318
  ...live.map((x) => ({ value: x.id, label: (x.label && x.label !== x.id ? x.label + ' (' + x.id + ')' : x.id) }))],
280
- onCommit: (v) => {
281
- setSessionDefaults({ profile: v || null });
282
- // the form's own per-agent memory follows, so both starts agree; a refusal is shown by the form itself
283
- pmPost('/api/profiles/prefer', { agent: 'claude', profile: v || null });
284
- setState({});
285
- },
319
+ // the form's own per-agent memory follows, so both starts agree; a refusal is shown by the form itself
320
+ onCommit: (v) => chooseDefaultProfile(v),
286
321
  attrs: { 'data-fr': 'default-profile' },
287
322
  });
288
323
  const flags = d.flags;
@@ -395,9 +430,9 @@ function readKitStatus(ws, force) {
395
430
 
396
431
  const say = (fields) => { Object.assign(upd, fields); setState({}); };
397
432
 
398
- async function checkUpdates(ws, local) {
433
+ async function checkUpdates(ws) {
399
434
  say({ phase: 'check', message: '', error: '', output: '' });
400
- const j = await pmPost('/api/kit/check', { ws, local });
435
+ const j = await pmPost('/api/kit/check', { ws });
401
436
  if (j && j.ok !== false) {
402
437
  kitByWs.set(ws, { st: j, at: Date.now() });
403
438
  say({ phase: null, latest: j.latest, compare: { appBehind: !!j.appBehind, kitBehind: !!j.kitBehind }, source: j.source });
@@ -480,13 +515,9 @@ function updatesSection(scope) {
480
515
  onClick: () => updateAll(ws, app, { appBehind: !!cmp.appBehind, kitBehind: !!cmp.kitBehind && kitPresent }),
481
516
  })));
482
517
  }
483
- kids.push(el('div', { class: 'fr-set__line' }, el('label', { class: 'fr-set__checkbox' },
484
- el('input', { type: 'checkbox', checked: !!upd.checkLocal, onChange: (e) => { upd.checkLocal = e.target.checked; setState({}); } }),
485
- el('span', null, 'Check local git version')
486
- )));
487
518
  const line = upd.phase === 'check' ? P.updatesChecking : upd.phase === 'kit' ? P.updateKitBusy
488
519
  : upd.message ? upd.message : upd.latest ? (cmp.appBehind || cmp.kitBehind ? P.updatesLatest(upd.latest) : P.updatesCurrent(upd.latest)) : P.updatesNever;
489
- const sourceNote = upd.source === 'local-git' ? ' (from local git)' : upd.source ? ` (from ${upd.source})` : '';
520
+ const sourceNote = upd.source === 'local' ? ' (from the local package)' : '';
490
521
  kids.push(el('p', { class: 'fr-set__note', role: 'status', 'aria-live': 'polite', 'data-fr': 'update-state' }, line + sourceNote));
491
522
  if (upd.error) kids.push(el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'update-error' }, upd.error));
492
523
  if (upd.output) kids.push(el('pre', { class: 'fr-set__output', 'data-fr': 'update-output' }, upd.output));
@@ -500,7 +531,7 @@ function checkButton(scope) {
500
531
  const ws = wsOfRepo(repo) || wsOfRepo(activeProduct());
501
532
  return button({
502
533
  label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: !!upd.phase || !ws,
503
- attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws, upd.checkLocal),
534
+ attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws),
504
535
  });
505
536
  }
506
537
 
@@ -5,6 +5,14 @@
5
5
  * rules. The words follow the PMOS-STE instructional register: short sentences, one instruction each, the active voice.
6
6
  */
7
7
 
8
+ /** One sentence naming where detection looked, built from /api/profiles `searched` (names only). */
9
+ function P_searched(sr) {
10
+ const files = sr.files && sr.files.length ? 'read ' + sr.files.join(', ') : 'found no shell start-up file';
11
+ const dirs = sr.folders && sr.folders.length ? '; folders ' + sr.folders.map((f) => f.name + (f.claude ? '' : ' (no Claude files in it)')).join(', ') : '; no ~/.claude-<name> folder';
12
+ const lau = sr.launchers && sr.launchers.length ? '; launchers ' + sr.launchers.join(', ') : '';
13
+ return 'Looked: ' + files + dirs + lau + '. Press Look again after you add one.';
14
+ }
15
+
8
16
  export const COPY = {
9
17
  repo: {
10
18
  all: 'All repositories',
@@ -177,7 +185,12 @@ export const COPY = {
177
185
  profilesReading: 'Reading ~/.claude/profiles…',
178
186
  profilesUnread: (e) => 'The profiles could not be read — ' + (e || 'no reason given'),
179
187
  profilesFound: (ids, seen) => 'Found ' + ids.length + ': ' + ids.join(', ') + '.' + (seen && seen.length ? ' ' + seen.join(', ') + (seen.length === 1 ? ' was' : ' were') + ' detected from your shell commands or config folders; a session under ' + (seen.length === 1 ? 'it' : 'one') + ' sets only CLAUDE_CONFIG_DIR.' : ''),
180
- profilesNone: (dir) => 'No profile in ' + (dir || '~/.claude/profiles') + ' yet.',
188
+ profilesNone: (dir, srch) => 'No profile in ' + (dir || '~/.claude/profiles') + ' yet.' + (srch ? ' ' + P_searched(srch) : ''),
189
+ profileDefaultBadge: 'Default', profileDetectedBadge: 'Detected', profileUseDefault: 'Use as default', profileNoDefault: 'Stop using as default',
190
+ profilesLookAgain: 'Look again',
191
+ profileMeta: (x) => x.detected
192
+ ? 'Detected from ' + ({ shell: 'a shell command', launcher: 'a launcher script', 'config-dir': 'a config folder' }[x.detected.source] || 'your machine') + ' · sets CLAUDE_CONFIG_DIR'
193
+ : [x.label && x.label !== x.id ? x.label : null, x.account, x.env_count ? x.env_count + ' variable' + (x.env_count === 1 ? '' : 's') : null].filter(Boolean).join(' · ') || 'Profile descriptor',
181
194
  pfId: 'Id (the file name)', pfLabel: 'Label', pfAccount: 'Account, shown as written and not checked', pfMcp: 'MCP server names, declared only',
182
195
  pfEnvHead: 'Environment variables applied to the session',
183
196
  pfEnvName: 'Variable name', pfEnvValue: 'Value', pfEnvValuePh: 'value (hidden)', pfAddVar: 'Add variable',
@@ -42,12 +42,24 @@ const PILL = 'font-family:var(--mono);font-size:10px;padding:2px 6px;background:
42
42
  /* ── the Claude profiles (the detail Settings opens) ─────────────────────────────────────────── */
43
43
 
44
44
  /** The registered profiles: the live data layer's, else (fixture mode only) the seed's. */
45
- const profileList = () => state.profiles || (FIXTURE ? FX.PROFILES : []);
45
+ const profileList = () => {
46
+ const p = state.profiles; // the /api/profiles answer ({ profiles: [...] }), or a bare list
47
+ return Array.isArray(p) ? p : p && Array.isArray(p.profiles) ? p.profiles : (FIXTURE ? FX.PROFILES : []);
48
+ };
46
49
 
47
50
  function profBody(det) {
48
- let pf = { id: '', account: '', mcp: [], noMcp: false, note: '' };
51
+ let pf = { id: '', account: '', mcp: [], noMcp: false, note: '', detected: null };
49
52
  const x = profileList().find((z) => z.id === det.id);
50
- if (x) pf = { ...x, noMcp: x.mcp.length === 0 };
53
+ if (x) pf = { ...pf, ...x, account: x.account || '', mcp: x.mcp || [], note: x.note || (x.detected ? 'Found on this machine, not written by PMOS. A session started under it sets only CLAUDE_CONFIG_DIR.' : ''), noMcp: !(x.mcp && x.mcp.length) };
54
+
55
+ // a profile PMOS found rather than wrote has no descriptor file: say how it was found, in tokens-and-classes only
56
+ if (pf.detected) {
57
+ const how = { shell: 'a shell command', launcher: 'a launcher script', 'config-dir': 'a config folder' }[pf.detected.source] || 'this machine';
58
+ return [el('div', { class: 'fr-fp-stack', 'data-fr': 'profile-detected' },
59
+ el('span', { class: 'fr-fp-label' }, iv(pf.id)),
60
+ el('p', { class: 'fr-fp-sub' }, 'Detected from ' + how + '. ' + pf.note),
61
+ el('p', { class: 'fr-fp-sub' }, iv(pf.detected.from)))];
62
+ }
51
63
 
52
64
  const out = [];
53
65
  out.push(el('div', { style: HEAD_BLOCK },
@@ -436,6 +436,8 @@ function profileById(id) {
436
436
  // `env` VALUES NEVER CROSS THE WIRE (contract §1.3). An environment overlay is exactly where a
437
437
  // credential lives, and D08's never-log-headers discipline covers anything the browser receives.
438
438
  // The names are shipped so the pane can say WHAT was applied; the values are not.
439
+ // a path shown with the home folder as ~, so a config folder (which is also the CLAUDE_CONFIG_DIR value) is not sent whole
440
+ function tildePath(f) { const h = os.homedir(); return typeof f === 'string' && h && (f === h || f.startsWith(h + path.sep)) ? '~' + f.slice(h.length) : f; }
439
441
  function profileWire(p, read) {
440
442
  const env = p && p.env && typeof p.env === 'object' ? p.env : {};
441
443
  const keys = Object.keys(env);
@@ -443,7 +445,7 @@ function profileWire(p, read) {
443
445
  id: p.id, label: p.label == null ? null : p.label, account: p.account == null ? null : p.account,
444
446
  mcp: Array.isArray(p.mcp) ? p.mcp.slice() : [],
445
447
  // where a profile PMOS found (rather than was given) came from: a shell file, a launcher script or a config folder
446
- detected: p.detected ? { source: p.detected.source, from: p.detected.from, line: p.detected.line } : null,
448
+ detected: p.detected ? { source: p.detected.source, from: tildePath(p.detected.from), line: p.detected.line } : null,
447
449
  env_count: keys.length, env_keys: keys,
448
450
  // Computed HERE, once, so no surface can re-word R6's five sentences.
449
451
  line: profilesLib.profileLine(p, read),
@@ -6108,6 +6110,8 @@ async function handleRequest(req, res) {
6108
6110
  dir: read.dir,
6109
6111
  profiles: (read.profiles || []).map((x) => profileWire(x, read)),
6110
6112
  broken: (read.broken || []).map((b) => ({ file: b.file, error: b.error })),
6113
+ // what detection looked at, so an empty list can say where it looked (names only, under the home folder)
6114
+ searched: (() => { try { return require('./lib/profile-discovery').searched(os.homedir()); } catch { return null; } })(),
6111
6115
  prefs: readPrefs(),
6112
6116
  error: read.error || null,
6113
6117
  });
@@ -7334,7 +7338,7 @@ async function handleRequest(req, res) {
7334
7338
  const r = await kitUpdate.latest({ source: kitUpdate.source() });
7335
7339
  const now = st();
7336
7340
  if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
7337
- return json(res, { ok: true, ws: w.id, ...now, latest: r.version, source: r.source || 'npm', ...kitUpdate.compare(now, r.version) });
7341
+ return json(res, { ok: true, ws: w.id, ...now, latest: r.version, source: r.source || 'registry', ...kitUpdate.compare(now, r.version) });
7338
7342
  }
7339
7343
  if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
7340
7344
  if (p === '/api/kit/update') {
@@ -4,6 +4,10 @@
4
4
  "profile-detection.test.js"
5
5
  ],
6
6
  "seconds": {
7
- "profile-detection.test.js": 8
8
- }
7
+ "profile-detection.test.js": 8,
8
+ "pm-profile-rows-test.js": 20
9
+ },
10
+ "panels": [
11
+ "pm-profile-rows-test.js"
12
+ ]
9
13
  }