create-pmos 0.99.0 → 0.101.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/guide/today.md +2 -0
- package/workspace/lib/kit-update.js +12 -7
- package/workspace/lib/profile-discovery.js +194 -0
- package/workspace/lib/profiles.js +18 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +3 -0
- package/workspace/public/pm/dest-settings.js +1 -1
- package/workspace/public/pm/frame-copy.js +1 -1
- package/workspace/public/pm/panel-newsess.js +7 -1
- package/workspace/public/pm/today-copy.js +1 -0
- package/workspace/public/pm/today-day.js +14 -10
- package/workspace/server.js +4 -2
- package/workspace/test-lists/workspace-profile-detection.json +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.101.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-08T00:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T00:30:09Z"}
|
|
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:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T00:30:09Z"}
|
|
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:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T00:30:09Z"}
|
|
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.
|
|
@@ -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.**
|
|
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.
|
|
43
43
|
|
|
44
44
|
A new screen or a renamed menu comes with the workspace app, not with the kit.
|
|
45
45
|
|
package/workspace/guide/today.md
CHANGED
|
@@ -100,6 +100,8 @@ When the job is a reply or a review, you can finish it in the card. Write your a
|
|
|
100
100
|
|
|
101
101
|
A meeting that needs prep is a row of **Needs you**, with the verb **Prepare**. Its detail shows the prep, how many steps are ready, the free time before the meeting, and the steps before it. A step that reads a source shows what the source says under it. A step that asks for your view, such as **Write your recommendation**, has a box for your note. The note is saved when you leave the box. It stays with the meeting on this computer and is not sent. A step that the brief already marked done stays checked.
|
|
102
102
|
|
|
103
|
+
When a meeting ends, its detail stays as it was. A line at the top says **ended** and the time it ended. The prep and the steps stay, in a quieter box, so you can still read what you prepared. The meeting leaves **Needs you**, and its notes show under it.
|
|
104
|
+
|
|
103
105
|
When you finish or put off a job or a meeting, mark it:
|
|
104
106
|
|
|
105
107
|
| Press | What it does |
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* app npx -y create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
|
|
18
18
|
* LOCAL SOURCE. PMOS_UPDATE_SOURCE names a built create-pmos package on this computer: its folder (with package.json, bin/
|
|
19
19
|
* and payload/ or workspace/) or its .tgz from `npm pack`. The check then reads that package's version instead of asking
|
|
20
|
-
* the registry, and the update runs `npx -y
|
|
20
|
+
* the registry, and the update runs `npx -y --package=<that path> -- create-pmos ...` in place of `npx -y create-pmos@<version> ...`. Nothing
|
|
21
21
|
* reaches the network, so an update can be tried before it is published. Unset means npm (the default).
|
|
22
22
|
* The version is the one the check found, so what is installed is what the PM was shown, not whatever `latest` is a
|
|
23
23
|
* minute later. The app upgrade stops this server (create-pmos runs `pmos stop`), so it runs DETACHED, writes its output
|
|
@@ -108,8 +108,13 @@ function source(env = process.env) {
|
|
|
108
108
|
return { kind: 'local', path: p, version: m[1] };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The npx arguments that run create-pmos: the registry package at a version, or the local path. A bare path is not
|
|
113
|
+
* enough: npx would try to run it as a command, so a local package goes in with --package.
|
|
114
|
+
*/
|
|
115
|
+
const npxArgs = (src, version, rest) => (src && src.kind === 'local'
|
|
116
|
+
? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
|
|
117
|
+
: ['-y', `${PKG_NAME}@${version}`, ...rest]);
|
|
113
118
|
|
|
114
119
|
function status({ root, appVersion, appDir, env }) {
|
|
115
120
|
const src = source(env);
|
|
@@ -175,7 +180,7 @@ async function updateKit(root, { version, exec = cp.execFile, source: src = sour
|
|
|
175
180
|
if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
|
|
176
181
|
if (src.kind === 'local' && src.error) return { ok: false, error: src.error };
|
|
177
182
|
if (!readKit(root).present) return { ok: false, error: 'This repository has no PMOS kit folder (pmos/KIT-VERSION)' };
|
|
178
|
-
const r = await run(exec, NPX,
|
|
183
|
+
const r = await run(exec, NPX, npxArgs(src, version, ['--update', String(root)]), { timeout: UPDATE_TIMEOUT_MS, cwd: String(root) });
|
|
179
184
|
const output = tail(r.stdout + (r.stderr ? '\n' + r.stderr : ''));
|
|
180
185
|
if (r.err && notServedYet(r.stdout + r.stderr, version)) return { ok: false, error: notServedYetError(version), output };
|
|
181
186
|
const bad = why(r, 'The kit update');
|
|
@@ -221,7 +226,7 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
|
|
|
221
226
|
fd = fs.openSync(logFile, 'w');
|
|
222
227
|
fs.writeSync(fd, `${new Date().toISOString()} clearing npm cache…\n`);
|
|
223
228
|
fs.writeSync(fd, `${new Date().toISOString()} ${NPM} cache clean --force\n`);
|
|
224
|
-
fs.writeSync(fd, `${new Date().toISOString()} ${NPX}
|
|
229
|
+
fs.writeSync(fd, `${new Date().toISOString()} ${NPX} ${npxArgs(src, version, ['workspace', '--reinstall', String(root)]).join(' ')}\n`);
|
|
225
230
|
} catch (e) { return { ok: false, error: `Could not write the log file: ${e.message}` }; }
|
|
226
231
|
try {
|
|
227
232
|
// Clear npm cache synchronously before spawning the update, so npx downloads the fresh version
|
|
@@ -230,7 +235,7 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
|
|
|
230
235
|
try { fs.appendFileSync(logFile, `Note: cache clear had an issue, but continuing: ${e.message}\n`); } catch { /* silent */ }
|
|
231
236
|
}
|
|
232
237
|
try {
|
|
233
|
-
const child = spawn(NPX,
|
|
238
|
+
const child = spawn(NPX, npxArgs(src, version, ['workspace', '--reinstall', String(root)]),
|
|
234
239
|
{ cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true, shell: IS_WIN });
|
|
235
240
|
// A child that could not start writes nothing itself: say why in the log, which the page names when it gives up.
|
|
236
241
|
if (child && typeof child.on === 'function') {
|
|
@@ -245,4 +250,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
|
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source,
|
|
253
|
+
module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, 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
|
|
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.
|
|
3
|
+
"version": "0.101.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.101.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -3583,6 +3583,9 @@
|
|
|
3583
3583
|
.td-day__mark[data-day-mark-state="done"] { border-color: var(--foreground); }
|
|
3584
3584
|
.td-day__ready { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 12px; }
|
|
3585
3585
|
.td-day__prep { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); background: var(--background); }
|
|
3586
|
+
.td-day__ended { margin: 0; padding: 8px 12px; border-left: 2px solid var(--foreground); background: var(--muted); font-size: 14px; }
|
|
3587
|
+
/* an ended meeting keeps its prep, quieter: the steps stay readable, the past is not a call to act */
|
|
3588
|
+
.td-day__detail:has(.td-day__ended) .td-day__prep { background: var(--muted); }
|
|
3586
3589
|
.td-day__fold { border-top: 1px solid var(--border); padding-top: 6px; }
|
|
3587
3590
|
.td-day__fold > summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); }
|
|
3588
3591
|
.td-day__fold[open] > summary { margin-bottom: 6px; }
|
|
@@ -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');
|
|
@@ -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',
|
|
@@ -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 '
|
|
@@ -366,6 +366,7 @@ export const COPY = {
|
|
|
366
366
|
from: (who, where) => (who && where ? `${who} in ${where}` : who || where || ''),
|
|
367
367
|
howTitle: (d, n) => `How to do it · ${d} of ${n}`,
|
|
368
368
|
beforeTitle: (at) => `Before ${at}`,
|
|
369
|
+
endedAt: (at) => `This meeting ended at ${at}. Its prep and steps stay below.`,
|
|
369
370
|
more: 'More context',
|
|
370
371
|
moreMeeting: (during, after) => `In the meeting (${during}) · After (${after})`,
|
|
371
372
|
readOnly: 'Read only. Nothing is sent before you press a button that says so.',
|
|
@@ -176,10 +176,12 @@ function model(d, view) {
|
|
|
176
176
|
const ended = Number.isFinite(f) && f <= now;
|
|
177
177
|
// a meeting needs prep when the brief gave a prep line or Before steps (the explainer's contradiction: Before steps
|
|
178
178
|
// with no prep line read "Nothing to prepare" while the Agenda counted them as prep left)
|
|
179
|
-
|
|
179
|
+
// the prep stays with the meeting once it ends (the PM, 2026-10-07): hadPrep keeps the detail, needsPrep drives the lists
|
|
180
|
+
const hadPrep = !!e.prep || before.length > 0;
|
|
181
|
+
const needsPrep = !ended && hadPrep;
|
|
180
182
|
const st = mark && mark.state;
|
|
181
183
|
const group = !needsPrep ? null : st === 'done' || st === 'dismissed' ? 'done' : st === 'snoozed' ? 'later' : 'needs';
|
|
182
|
-
return { ...e, id: e.id || 'ev-' + i, s, f, time: hhmm(e.start), ended, mark, before, needsPrep, group };
|
|
184
|
+
return { ...e, id: e.id || 'ev-' + i, s, f, time: hhmm(e.start), ended, mark, before, hadPrep, needsPrep, group };
|
|
183
185
|
}).sort((a, b) => a.s - b.s);
|
|
184
186
|
const settings = (d && d.settings) || null;
|
|
185
187
|
const scopes = (settings && settings.scopes) || {};
|
|
@@ -845,17 +847,17 @@ function prepBlock(ctx, e) {
|
|
|
845
847
|
const d = steps.filter((x) => x.done).length; const total = steps.length;
|
|
846
848
|
const left = total - d;
|
|
847
849
|
const free = freeBefore(m, e);
|
|
848
|
-
const fit = !total ? null : !left ? A.fit.ok : free >= left * 15 ? A.fit.time(left, free) : A.fit.short(left, free);
|
|
850
|
+
const fit = !total || e.ended ? null : !left ? A.fit.ok : free >= left * 15 ? A.fit.time(left, free) : A.fit.short(left, free);
|
|
849
851
|
const open = !e.mark || !e.mark.state;
|
|
850
852
|
const link = Array.isArray(e.links) ? e.links.find((l) => isUrl(l.url)) : null;
|
|
851
853
|
return el('div', { class: 'td-day__prep', 'data-day': 'prep' },
|
|
852
|
-
el('div', { class: 'td-day__jobtop' }, verbChip('prepare'), dueChip(m, e.mark, e.s, true)),
|
|
854
|
+
el('div', { class: 'td-day__jobtop' }, verbChip('prepare'), e.ended ? null : dueChip(m, e.mark, e.s, true)),
|
|
853
855
|
e.prep ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, e.prep) : null,
|
|
854
856
|
markBanner(ctx, e.id, e.mark, true),
|
|
855
857
|
total ? el('div', { class: 'td-day__ready', 'data-day': 'readiness' }, meter({ done: d, total, label: A.readiness(d, total) }), fit ? el('span', { class: 'td-day__dim' }, fit) : null) : null,
|
|
856
858
|
total ? el('div', { class: 'td-day__phase' }, el('h4', null, A.beforeTitle(e.time)), stepChecks(ctx, e.id, steps, { refs, mark: e.mark })) : null,
|
|
857
859
|
refsBlock(refs, steps),
|
|
858
|
-
open && link && left ? el('div', { class: 'td-day__acts', 'data-day': 'cta' }, primaryLink((link.label || link.url) + ' ↗', link.url, { 'data-day-link': 'prep' })) : null,
|
|
860
|
+
open && link && left && !e.ended ? el('div', { class: 'td-day__acts', 'data-day': 'cta' }, primaryLink((link.label || link.url) + ' ↗', link.url, { 'data-day-link': 'prep' })) : null,
|
|
859
861
|
marksBar(ctx, { id: e.id, title: e.title, kind: 'event', start: e.s, strong: total > 0 && !left }));
|
|
860
862
|
}
|
|
861
863
|
|
|
@@ -896,10 +898,11 @@ function eventDetail(ctx, e) {
|
|
|
896
898
|
onClick: async () => { if (await addGoal(ctx, e.title, ini ? ini.id : null)) toast({ text: D.goalAdded }); } })) : null];
|
|
897
899
|
return [
|
|
898
900
|
dhead(D.kind.meeting, e.title),
|
|
899
|
-
el('p', { class: 'td-
|
|
901
|
+
e.ended ? el('p', { class: 'td-day__ended', 'data-day': 'ended', role: 'status' }, pill(D.pill.ended, ''), ' ', A.endedAt(hhmm(e.end || e.f))) : null,
|
|
902
|
+
el('p', { class: 'td-day__dim td-day__mono' }, `${e.time}${e.end ? '–' + hhmm(e.end) : ''}${e.with ? ' · ' + e.with : ''}`),
|
|
900
903
|
e.with ? peopleLinks(ctx.ws, e.with, { 'data-day': 'people' }) : null,
|
|
901
904
|
e.purpose ? el('p', null, el('b', null, D.purpose + ': '), e.purpose) : null,
|
|
902
|
-
e.
|
|
905
|
+
e.hadPrep ? prepBlock(ctx, e) : null,
|
|
903
906
|
ini ? el('div', { class: 'td-day__ini', 'data-day': 'initiative' }, el('span', null, D.initiative + ': ', el('b', { class: 'td-day__mono' }, ini.id), ini.why ? el('span', { class: 'td-day__dim' }, ' (' + D.suggested(ini.why) + ')') : null),
|
|
904
907
|
ctx.openIni ? button({ label: D.openInitiative, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'open-initiative' }, onClick: () => ctx.openIni(ini.id) }) : null) : null,
|
|
905
908
|
tie,
|
|
@@ -907,10 +910,11 @@ function eventDetail(ctx, e) {
|
|
|
907
910
|
after,
|
|
908
911
|
notesBlock(e, { ws: ctx.ws, initiative: ini }),
|
|
909
912
|
// with prep, the Before steps are the checklist above; During, After and the mail fold away (PRD D2)
|
|
910
|
-
|
|
913
|
+
// once it ends, the plan stays where it was; the mail moves up into the notes above, so it is not shown twice
|
|
914
|
+
e.hadPrep && ((steps.during || []).length || (steps.after || []).length || (!e.ended && mailBlock))
|
|
911
915
|
? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.moreMeeting((steps.during || []).length, (steps.after || []).length)),
|
|
912
|
-
list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock) : null,
|
|
913
|
-
!e.
|
|
916
|
+
list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), e.ended ? null : mailBlock) : null,
|
|
917
|
+
!e.hadPrep ? [list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), e.ended ? null : mailBlock] : null,
|
|
914
918
|
Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
|
|
915
919
|
];
|
|
916
920
|
}
|
package/workspace/server.js
CHANGED
|
@@ -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 =
|
|
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 });
|