create-pmos 0.100.0 → 0.102.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.100.0",
3
+ "version": "0.102.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@42d0a34
1
+ kernel-source: pmos@6b369e8
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:23:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:36:21Z"}
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:23:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:36:21Z"}
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:23:24Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:36:21Z"}
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. 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`. 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.
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.
@@ -166,6 +166,20 @@ 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
+
169
183
  /**
170
184
  * npm lists a new version in the check a moment before its download is served everywhere. A download asked for in that
171
185
  * gap answers 404, and npm's CDN keeps the 404 for up to five minutes (Cache-Control max-age=300), so the same press
@@ -250,4 +264,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
250
264
  }
251
265
  }
252
266
 
253
- module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
267
+ module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, latestLocal, updateKit, updateEnv, upgradeApp, whereFrom };
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+ /*
3
+ * Detected Claude profiles — a PURE reader (initiative `workspace-profile-detection`).
4
+ *
5
+ * Most people who run Claude Code under more than one account do not keep a PMOS descriptor. They keep a second
6
+ * CONFIG DIRECTORY and a command that points Claude at it:
7
+ *
8
+ * alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'
9
+ *
10
+ * `CLAUDE_CONFIG_DIR` is how Claude Code picks where its settings, credentials and history live, so one directory is
11
+ * one profile. PMOS can start a session under such a profile with the same environment overlay a descriptor gives:
12
+ * the one variable `CLAUDE_CONFIG_DIR`. This module finds them. It reads three places under the home directory and
13
+ * writes nothing, runs nothing and never opens a credential file:
14
+ *
15
+ * 1. shell start-up files and fish functions — an `alias`, a function or a one-line command that sets
16
+ * `CLAUDE_CONFIG_DIR` and runs `claude`. The alias name is the profile id.
17
+ * 2. launcher scripts named `claude-*` in the usual bin directories that set `CLAUDE_CONFIG_DIR`.
18
+ * 3. config directories that look like Claude's own — `~/.claude-<name>`, `~/.claude_<name>`,
19
+ * `~/.config/claude-<name>` — recognised by a file or folder only Claude writes there. The id is
20
+ * `claude-<name>`.
21
+ *
22
+ * Every result names WHERE it was found (`source`), because a detected profile is a guess from files PMOS did not
23
+ * write, and the surface must say so. The overlay is exactly `{ CLAUDE_CONFIG_DIR: <absolute path> }`: any other
24
+ * variable an alias sets (a key, a proxy) is left alone, and the surface says only one variable is applied.
25
+ *
26
+ * A descriptor under `~/.claude/profiles` always wins over a detected profile of the same id (see profiles.js).
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
32
+ /** Something only Claude Code writes into its config directory. A folder with none of these is not offered. */
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'];
35
+ const FISH_FUNCTIONS = '.config/fish/functions';
36
+ const BIN_DIRS = ['.local/bin', 'bin', '.bin', '.config/claude/bin'];
37
+ const MAX_TEXT = 256 * 1024; // a start-up file larger than this is not read
38
+ const MAX_SCRIPT = 16 * 1024; // a launcher script larger than this is a binary or something else
39
+
40
+ const safeRead = (file, max) => {
41
+ try {
42
+ const st = fs.statSync(file);
43
+ if (!st.isFile() || st.size > max) return null;
44
+ return fs.readFileSync(file, 'utf8');
45
+ } catch { return null; }
46
+ };
47
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
48
+
49
+ /** `~`, `$HOME` and `${HOME}` at the start of a path, resolved against `home`; quotes removed. null when the value is
50
+ * not a plain path (it holds another variable, a command substitution or a glob). */
51
+ function resolveDir(raw, home) {
52
+ let v = String(raw || '').trim().replace(/^["']|["']$/g, '');
53
+ if (!v) return null;
54
+ v = v.replace(/^\$\{?HOME\}?(?=\/|$)/, home).replace(/^~(?=\/|$)/, home);
55
+ if (/[$`*?]|\$\(/.test(v)) return null;
56
+ if (!path.isAbsolute(v)) return null;
57
+ return path.normalize(v).replace(/\/+$/, '') || null;
58
+ }
59
+
60
+ /** The value after `CLAUDE_CONFIG_DIR=` in one line of shell: quoted or bare. */
61
+ function configDirOf(line, home) {
62
+ const m = /CLAUDE_CONFIG_DIR=(?:"([^"]*)"|'([^']*)'|([^\s;'"`)}]+))/.exec(line);
63
+ if (!m) return null;
64
+ return resolveDir(m[1] != null ? m[1] : m[2] != null ? m[2] : m[3], home);
65
+ }
66
+
67
+ /** `claude` run as a command: after the assignment, as a word of its own (not part of the alias name `claude-work`). */
68
+ const runsClaude = (s) => /(^|[\s;&|(])(?:command\s+|exec\s+|env\s+)?(?:\S*\/)?claude(?=[\s;&|)'"}]|$)/.test(s);
69
+
70
+ /** Names defined on one line of a shell start-up file: `alias n=…`, `n() { … }`, `function n …`, fish `alias n '…'`. */
71
+ function definedNames(line) {
72
+ let m = /^\s*alias\s+(?:-\w+\s+)?([A-Za-z0-9._-]+)(?:=|\s)/.exec(line);
73
+ if (m) return m[1];
74
+ m = /^\s*(?:function\s+)?([A-Za-z0-9._-]+)\s*\(\s*\)\s*\{/.exec(line);
75
+ if (m) return m[1];
76
+ m = /^\s*function\s+([A-Za-z0-9._-]+)/.exec(line);
77
+ return m ? m[1] : null;
78
+ }
79
+
80
+ /** Profiles named in the text of one start-up file. `file` and the line number are kept as provenance. */
81
+ function fromShellText(text, file, home) {
82
+ const out = [];
83
+ const lines = text.split(/\r?\n/);
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i];
86
+ if (!line.includes('CLAUDE_CONFIG_DIR') || /^\s*#/.test(line)) continue;
87
+ if (/^\s*(?:export|set\s+-\w*x|declare|typeset)\b/.test(line)) continue; // a plain export names no command
88
+ let name = definedNames(line);
89
+ let tail = [line];
90
+ // a function written over several lines: the name is on an earlier line, the command on this one or the next two
91
+ if (!name) {
92
+ for (let j = i - 1; j >= 0 && j >= i - 4 && !name; j--) {
93
+ if (/^\s*(?:\}|end\b)/.test(lines[j])) break;
94
+ if ((/\{\s*$/.test(lines[j]) || /^\s*function\s/.test(lines[j])) && definedNames(lines[j])) name = definedNames(lines[j]);
95
+ }
96
+ tail = lines.slice(i, i + 3).filter((l) => !/^\s*#/.test(l));
97
+ }
98
+ const dir = configDirOf(line, home);
99
+ const assign = line.indexOf('CLAUDE_CONFIG_DIR=');
100
+ const first = (assign < 0 ? line : line.slice(assign + 'CLAUDE_CONFIG_DIR='.length)).replace(/^(?:"[^"]*"|'[^']*'|[^\s;'"`)}]+)/, '');
101
+ const after = [first, ...tail.slice(1)].join('\n');
102
+ if (!name || !dir || !runsClaude(after)) continue;
103
+ out.push({ id: name, dir, source: 'shell', from: file, line: i + 1 });
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Launcher scripts: `claude-work` on PATH whose text sets CLAUDE_CONFIG_DIR. */
109
+ function fromLauncher(file, home) {
110
+ const text = safeRead(file, MAX_SCRIPT);
111
+ if (!text || text.includes('\0') || !text.includes('CLAUDE_CONFIG_DIR')) return null;
112
+ for (const line of text.split(/\r?\n/)) {
113
+ if (/^\s*#/.test(line)) continue;
114
+ const dir = configDirOf(line, home);
115
+ if (dir) return { id: path.basename(file), dir, source: 'launcher', from: file, line: null };
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function looksLikeConfigDir(dir) {
121
+ return CONFIG_MARKERS.some((m) => { try { fs.accessSync(path.join(dir, m)); return true; } catch { return false; } });
122
+ }
123
+
124
+ /** `~/.claude-work`, `~/.claude_work`, `~/.config/claude-work` → `claude-work`. */
125
+ function fromConfigDirs(home) {
126
+ const out = [];
127
+ const scan = (parent, re) => {
128
+ let names = [];
129
+ try { names = fs.readdirSync(parent); } catch { return; }
130
+ for (const n of names.slice().sort()) {
131
+ const m = re.exec(n);
132
+ if (!m) continue;
133
+ const dir = path.join(parent, n);
134
+ if (!isDir(dir) || !looksLikeConfigDir(dir)) continue;
135
+ out.push({ id: ('claude-' + m[1]).toLowerCase(), dir, source: 'config-dir', from: dir, line: null });
136
+ }
137
+ };
138
+ scan(home, /^\.claude[-_.]([A-Za-z0-9][A-Za-z0-9._-]*)$/);
139
+ scan(path.join(home, '.config'), /^claude[-_]([A-Za-z0-9][A-Za-z0-9._-]*)$/);
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * detectProfiles(homeDir) → [{ id, label, account:null, mcp:[], env:{CLAUDE_CONFIG_DIR}, path, detected:{source, from, line} }]
145
+ * Sorted by id. When one id turns up in several places the shell definition wins over a launcher, a launcher over a bare
146
+ * directory: the command is what the PM types, so its directory is the one that is meant. A profile whose directory does
147
+ * not exist is dropped (the command would start Claude on an empty home, which is a new login, not a profile).
148
+ */
149
+ function detectProfiles(homeDir) {
150
+ if (typeof homeDir !== 'string' || !homeDir) return [];
151
+ const found = [];
152
+
153
+ for (const rel of RC_FILES) {
154
+ const file = path.join(homeDir, rel);
155
+ const text = safeRead(file, MAX_TEXT);
156
+ if (text) found.push(...fromShellText(text, file, homeDir));
157
+ }
158
+ const fishDir = path.join(homeDir, FISH_FUNCTIONS);
159
+ try {
160
+ for (const n of fs.readdirSync(fishDir).sort()) {
161
+ if (!n.endsWith('.fish')) continue;
162
+ const file = path.join(fishDir, n);
163
+ const text = safeRead(file, MAX_SCRIPT);
164
+ if (!text || !text.includes('CLAUDE_CONFIG_DIR')) continue;
165
+ const dir = configDirOf(text, homeDir);
166
+ if (dir) found.push({ id: n.slice(0, -5), dir, source: 'shell', from: file, line: null });
167
+ }
168
+ } catch { /* no fish functions */ }
169
+ for (const rel of BIN_DIRS) {
170
+ const bin = path.join(homeDir, rel);
171
+ let names = [];
172
+ try { names = fs.readdirSync(bin); } catch { continue; }
173
+ for (const n of names.slice().sort()) {
174
+ if (!/^claude[-_.]/.test(n)) continue;
175
+ const hit = fromLauncher(path.join(bin, n), homeDir);
176
+ if (hit) found.push(hit);
177
+ }
178
+ }
179
+ found.push(...fromConfigDirs(homeDir));
180
+
181
+ const rank = { shell: 0, launcher: 1, 'config-dir': 2 };
182
+ const byId = new Map();
183
+ for (const f of found) {
184
+ if (!ID_RE.test(f.id) || f.id === 'claude' || !isDir(f.dir)) continue;
185
+ const cur = byId.get(f.id);
186
+ if (!cur || rank[f.source] < rank[cur.source]) byId.set(f.id, f);
187
+ }
188
+ return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((f) => ({
189
+ id: f.id, label: f.id, account: null, mcp: [], env: { CLAUDE_CONFIG_DIR: f.dir },
190
+ path: f.from, detected: { source: f.source, from: f.from, line: f.line },
191
+ }));
192
+ }
193
+
194
+ module.exports = { detectProfiles, resolveDir, configDirOf, fromShellText, ID_RE };
@@ -151,7 +151,7 @@ function validate(raw, stem) {
151
151
  * with no home directory gets a stated failure; guessing one would be the same class of invention
152
152
  * this whole module refuses.
153
153
  */
154
- function readProfiles(homeDir) {
154
+ function readDescriptors(homeDir) {
155
155
  if (typeof homeDir !== 'string' || !homeDir) {
156
156
  return {
157
157
  ok: false, absent: false, dir: null, profiles: [], broken: [],
@@ -212,6 +212,23 @@ function readProfiles(homeDir) {
212
212
  return { ok: true, absent: false, dir, profiles, broken, error: null };
213
213
  }
214
214
 
215
+ /*
216
+ * readProfiles(homeDir, { detect }) — the descriptors above, and with `detect: true` also the profiles PMOS can SEE but
217
+ * the PM never wrote down: a config directory plus the command that points Claude at it (lib/profile-discovery.js).
218
+ * A detected profile carries `detected: { source, from, line }` so every surface can say where it came from. A descriptor
219
+ * of the same id always wins, and the four-way discrimination above is about the descriptor directory only: `absent`
220
+ * stays true when that directory is missing even though detected profiles are listed.
221
+ */
222
+ function readProfiles(homeDir, opts) {
223
+ const read = readDescriptors(homeDir);
224
+ if (!opts || !opts.detect || typeof homeDir !== 'string' || !homeDir) return read;
225
+ let found = [];
226
+ try { found = require('./profile-discovery').detectProfiles(homeDir); } catch { found = []; }
227
+ const taken = new Set([...read.profiles.map((p) => p.id), ...read.broken.map((b) => b.file.replace(/\.json$/, ''))]);
228
+ const extra = found.filter((p) => !taken.has(p.id));
229
+ return extra.length ? { ...read, profiles: [...read.profiles, ...extra] } : read;
230
+ }
231
+
215
232
  /*
216
233
  * The one line naming what a pane is running under — PRD R6, verbatim sentences.
217
234
  *
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.100.0",
3
+ "version": "0.102.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.100.0",
9
+ "version": "0.102.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.100.0",
3
+ "version": "0.102.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",
@@ -189,7 +189,7 @@ function profilesSection() {
189
189
  if (sess.msg) kids.push(el('p', { class: 'fr-set__note', role: 'status', 'data-fr': 'profiles-msg' }, sess.msg));
190
190
  kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'profiles-found' }, !sess.profiles ? P.profilesReading
191
191
  : sess.profiles.ok === false ? P.profilesUnread(sess.profiles.error)
192
- : found.length ? P.profilesFound(found.map((x) => x.id)) : P.profilesNone(sess.profiles.dir)));
192
+ : found.length ? P.profilesFound(found.map((x) => x.id), found.filter((x) => x.detected).map((x) => x.id)) : P.profilesNone(sess.profiles.dir)));
193
193
  const add = button({ label: P.profileNew, variant: 'outline', size: 'sm', icon: 'plus', attrs: { 'data-fr': 'profile-new', 'aria-expanded': sess.formOpen ? 'true' : 'false' },
194
194
  onClick: () => { sess.formOpen = !sess.formOpen; sess.msg = ''; setState({}); } });
195
195
  return section('profiles', P.profiles, kids, add, 'bot');
@@ -395,12 +395,12 @@ function readKitStatus(ws, force) {
395
395
 
396
396
  const say = (fields) => { Object.assign(upd, fields); setState({}); };
397
397
 
398
- async function checkUpdates(ws) {
398
+ async function checkUpdates(ws, local) {
399
399
  say({ phase: 'check', message: '', error: '', output: '' });
400
- const j = await pmPost('/api/kit/check', { ws });
400
+ const j = await pmPost('/api/kit/check', { ws, local });
401
401
  if (j && j.ok !== false) {
402
402
  kitByWs.set(ws, { st: j, at: Date.now() });
403
- say({ phase: null, latest: j.latest, compare: { appBehind: !!j.appBehind, kitBehind: !!j.kitBehind } });
403
+ say({ phase: null, latest: j.latest, compare: { appBehind: !!j.appBehind, kitBehind: !!j.kitBehind }, source: j.source });
404
404
  } else say({ phase: null, error: (j && j.error) || P.updateFailed });
405
405
  }
406
406
 
@@ -480,9 +480,14 @@ function updatesSection(scope) {
480
480
  onClick: () => updateAll(ws, app, { appBehind: !!cmp.appBehind, kitBehind: !!cmp.kitBehind && kitPresent }),
481
481
  })));
482
482
  }
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
+ )));
483
487
  const line = upd.phase === 'check' ? P.updatesChecking : upd.phase === 'kit' ? P.updateKitBusy
484
488
  : upd.message ? upd.message : upd.latest ? (cmp.appBehind || cmp.kitBehind ? P.updatesLatest(upd.latest) : P.updatesCurrent(upd.latest)) : P.updatesNever;
485
- kids.push(el('p', { class: 'fr-set__note', role: 'status', 'aria-live': 'polite', 'data-fr': 'update-state' }, line));
489
+ const sourceNote = upd.source === 'local-git' ? ' (from local git)' : upd.source ? ` (from ${upd.source})` : '';
490
+ kids.push(el('p', { class: 'fr-set__note', role: 'status', 'aria-live': 'polite', 'data-fr': 'update-state' }, line + sourceNote));
486
491
  if (upd.error) kids.push(el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'update-error' }, upd.error));
487
492
  if (upd.output) kids.push(el('pre', { class: 'fr-set__output', 'data-fr': 'update-output' }, upd.output));
488
493
  kids.push(el('p', { class: 'fr-set__note' }, P.updatesNote));
@@ -495,7 +500,7 @@ function checkButton(scope) {
495
500
  const ws = wsOfRepo(repo) || wsOfRepo(activeProduct());
496
501
  return button({
497
502
  label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: !!upd.phase || !ws,
498
- attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws),
503
+ attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws, upd.checkLocal),
499
504
  });
500
505
  }
501
506
 
@@ -176,7 +176,7 @@ export const COPY = {
176
176
  profileNew: 'New profile',
177
177
  profilesReading: 'Reading ~/.claude/profiles…',
178
178
  profilesUnread: (e) => 'The profiles could not be read — ' + (e || 'no reason given'),
179
- profilesFound: (ids) => 'Found ' + ids.length + ': ' + ids.join(', ') + '.',
179
+ 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
180
  profilesNone: (dir) => 'No profile in ' + (dir || '~/.claude/profiles') + ' yet.',
181
181
  pfId: 'Id (the file name)', pfLabel: 'Label', pfAccount: 'Account, shown as written and not checked', pfMcp: 'MCP server names, declared only',
182
182
  pfEnvHead: 'Environment variables applied to the session',
@@ -124,8 +124,12 @@ export function mountView(host, tab) {
124
124
  if (!v) inner.appendChild(el('div', { style: 'color:var(--muted-foreground);font-size:12px' }, 'this view is not known to this version'));
125
125
  else if (board) {
126
126
  inner.style.maxWidth = 'none';
127
- try { board.renderBoard(inner, { tab, view: tab.view, renderMode, ready: !v.mode || !!m, el, setChildren, state, setState, FIXTURE, openView, repo: currentProduct() }); }
128
- catch (e) { console.error('[pm] board ' + tab.view + ' failed:', e); renderMode(inner); }
127
+ const ctx = { tab, view: tab.view, renderMode, ready: !v.mode || !!m, el, setChildren, state, setState, FIXTURE, openView, repo: currentProduct() };
128
+ board.renderBoard(inner, ctx)
129
+ .then((boardEl) => {
130
+ if (boardEl) setChildren(inner, [boardEl]);
131
+ })
132
+ .catch((e) => { console.error('[pm] board ' + tab.view + ' failed:', e); renderMode(inner); });
129
133
  } else if (boardMods[tab.view] === null) {
130
134
  // the board module is still loading: draw nothing rather than flash the old list
131
135
  } else if (m && typeof m.renderMode === 'function') {
@@ -503,12 +503,18 @@ function profileSection() {
503
503
  : 'no default is set — new ' + agent + ' sessions start with no profile'))));
504
504
  }
505
505
 
506
- if (p.absent) {
506
+ if (p.absent && !good.length) {
507
507
  out.push(el('div', { style: SUB }, iv('no profile directory at ' + (p.dir || '?')
508
508
  + ' — nothing was read, so nothing is offered but “no profile”')));
509
509
  } else if (!good.length && !broken.length) {
510
510
  out.push(el('div', { style: SUB }, iv((p.dir || 'the profile directory') + ' was read and holds no descriptors')));
511
511
  }
512
+ const seen = good.filter((x) => x && x.detected);
513
+ if (seen.length) {
514
+ out.push(el('div', { class: 'fr-fp-sub', 'data-profile': 'detected' }, iv(seen.map((x) => x.id).join(', ')
515
+ + (seen.length === 1 ? ' was' : ' were') + ' found from your shell commands or config folders — a session under '
516
+ + (seen.length === 1 ? 'it' : 'one') + ' sets only CLAUDE_CONFIG_DIR')));
517
+ }
512
518
  if (broken.length) {
513
519
  out.push(el('div', { style: WARN },
514
520
  iv(broken.length + ' descriptor' + (broken.length === 1 ? '' : 's') + ' could not be parsed and '
@@ -54,12 +54,9 @@ function groupByInitiative(notes) {
54
54
  }
55
55
 
56
56
  function renderNoteRow(note, isSelected, onClick) {
57
- const summary = note.summary || '';
58
- const summaryText = summary
59
- .split('\n')
57
+ const summaryLines = (note.summary_lines || []);
58
+ const summaryText = summaryLines
60
59
  .slice(0, 2)
61
- .map((line) => line.trim())
62
- .filter(Boolean)
63
60
  .join(' ')
64
61
  .substring(0, 60);
65
62
 
@@ -424,7 +424,7 @@ function sessionEnv() {
424
424
  //
425
425
  // The read is not cached. A descriptor added while the workspace is open must appear on the next
426
426
  // look, and a stale copy of a file whose whole purpose is provenance is worse than a re-read.
427
- function readProfilesNow() { return profilesLib.readProfiles(os.homedir()); }
427
+ function readProfilesNow() { return profilesLib.readProfiles(os.homedir(), { detect: true }); }
428
428
  /** One descriptor by id, or null. Returns the READ too, because profileLine(null, read) needs it
429
429
  * to tell "no profiles on this machine" from "the read failed and we cannot say". */
430
430
  function profileById(id) {
@@ -442,6 +442,8 @@ function profileWire(p, read) {
442
442
  return {
443
443
  id: p.id, label: p.label == null ? null : p.label, account: p.account == null ? null : p.account,
444
444
  mcp: Array.isArray(p.mcp) ? p.mcp.slice() : [],
445
+ // 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,
445
447
  env_count: keys.length, env_keys: keys,
446
448
  // Computed HERE, once, so no surface can re-word R6's five sentences.
447
449
  line: profilesLib.profileLine(p, read),
@@ -3752,7 +3754,7 @@ function prepareNextStep(body) {
3752
3754
  const preview = startPreview({ initiative: id, ws: w.id });
3753
3755
  if (!preview.ok) return { ...head, refused: { code: 'no-plan', why: preview.error || 'no branch and tree could be chosen' }, plan: null, prompt: null, prompt_sha: null, sources: [], judgment: { on: judgmentOn } };
3754
3756
  let mcp = [];
3755
- if (body.profile) { try { const rp = require('./lib/profiles').readProfiles(os.homedir()); const pr = rp.ok && (rp.profiles || []).find((x) => x.id === body.profile); if (pr && Array.isArray(pr.mcp)) mcp = pr.mcp; } catch { mcp = []; } }
3757
+ if (body.profile) { try { const rp = readProfilesNow(); const pr = rp.ok && (rp.profiles || []).find((x) => x.id === body.profile); if (pr && Array.isArray(pr.mcp)) mcp = pr.mcp; } catch { mcp = []; } }
3756
3758
  const sources = nextStepActions.sourcesFor(item, mcp);
3757
3759
  const prompt = nextStepActions.buildPrompt({ initiative: id, title: got.title, item, branch: preview.branch || null, sources, judgment: judgmentLib.fragment(judgmentOn) });
3758
3760
  const sha = nextStepActions.planHash({ prompt, branch: preview.branch, wsId: preview.wsId, plan: preview.plan, judgment: judgmentOn });
@@ -7327,12 +7329,12 @@ async function handleRequest(req, res) {
7327
7329
  // the page did not show.
7328
7330
  const w = body.ws ? workspaces.get(String(body.ws)) : null;
7329
7331
  if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
7330
- const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname });
7332
+ const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname, env: process.env });
7331
7333
  if (p === '/api/kit/check') {
7332
7334
  const r = await kitUpdate.latest({ source: kitUpdate.source() });
7333
7335
  const now = st();
7334
7336
  if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
7335
- return json(res, { ok: true, ws: w.id, ...now, latest: r.version, ...kitUpdate.compare(now, r.version) });
7337
+ return json(res, { ok: true, ws: w.id, ...now, latest: r.version, source: r.source || 'npm', ...kitUpdate.compare(now, r.version) });
7336
7338
  }
7337
7339
  if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
7338
7340
  if (p === '/api/kit/update') {
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "workspace-profile-detection: lib/profile-discovery.js and the profiles it adds to /api/profiles.",
3
+ "modules": [
4
+ "profile-detection.test.js"
5
+ ],
6
+ "seconds": {
7
+ "profile-detection.test.js": 8
8
+ }
9
+ }