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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/layout.md +2 -2
- package/workspace/lib/kit-update.js +1 -15
- package/workspace/lib/profile-discovery.js +28 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dest-settings.js +57 -26
- package/workspace/public/pm/frame-copy.js +14 -1
- package/workspace/public/pm/modes.js +15 -3
- package/workspace/server.js +6 -2
- package/workspace/test-lists/workspace-profile-detection.json +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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,
|
|
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', '.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.105.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
433
|
+
async function checkUpdates(ws) {
|
|
399
434
|
say({ phase: 'check', message: '', error: '', output: '' });
|
|
400
|
-
const j = await pmPost('/api/kit/check', { ws
|
|
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
|
|
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
|
|
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 = () =>
|
|
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
|
|
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 },
|
package/workspace/server.js
CHANGED
|
@@ -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 || '
|
|
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') {
|