create-pmos 0.13.0 → 0.15.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/README.md +39 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/focus.test.js +650 -0
- package/workspace/handoff.test.js +443 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/drive-guard.js +98 -0
- package/workspace/lib/drive-plan.js +97 -0
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +127 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package.json +4 -2
- package/workspace/profiles.test.js +362 -0
- package/workspace/public/app.js +2318 -208
- package/workspace/public/index.html +158 -28
- package/workspace/public/styles.css +865 -51
- package/workspace/repo-scan.test.js +327 -0
- package/workspace/server.js +914 -23
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Claude profile descriptors — a PURE reader (initiative `workspace-profiles`, PRD R1/R2/R4/R6/R7).
|
|
4
|
+
*
|
|
5
|
+
* The workspace lets the PM name a profile when a session's process is created. This module is the
|
|
6
|
+
* half of that feature which decides WHAT IS TRUE: it reads `<home>/.claude/profiles/*.json` and
|
|
7
|
+
* reports what it found. It never spawns anything, never writes, and never touches the network.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS MODULE IS NOT ALLOWED TO IMPLY. PMOS holds no credential, runs no MCP client, and
|
|
10
|
+
* cannot ask a running `claude` what configuration it is really using. So every value here is
|
|
11
|
+
* "what a file on this machine declares", never "what is true of the world". The naming below is
|
|
12
|
+
* deliberately clumsy in places (`declared`, `recorded`) because the shorter words all read as
|
|
13
|
+
* verified facts.
|
|
14
|
+
*
|
|
15
|
+
* HONESTY BY CONSTRUCTION — readProfiles() returns a FOUR-way discrimination, not a boolean.
|
|
16
|
+
* These are four different facts about the world and collapsing any pair of them produces a
|
|
17
|
+
* specific, known lie:
|
|
18
|
+
*
|
|
19
|
+
* { ok:true, absent:true, profiles:[], broken:[] } no ~/.claude/profiles directory.
|
|
20
|
+
* A calm, stated absence. Nothing was read because there was nothing to read. The picker
|
|
21
|
+
* renders the path and says so. It must NOT render `work`/`personal` chips: those are the
|
|
22
|
+
* names the PM's machine is expected to carry, not names PMOS ships, and drawing them here
|
|
23
|
+
* is the PRD's stated FAIL — a picker appears, a choice is made, and nothing was applied.
|
|
24
|
+
*
|
|
25
|
+
* { ok:true, absent:false, profiles:[], broken:[] } the directory exists and is empty.
|
|
26
|
+
* A SUCCESSFUL READ OF NOTHING. Same consequence for the PM, different fact: the directory
|
|
27
|
+
* was created and left empty rather than never created. Calm, not red.
|
|
28
|
+
*
|
|
29
|
+
* { ok:true, absent:false, profiles:[…], broken:[…] } some descriptors read, some did not.
|
|
30
|
+
* R4: one bad file must not take the others down. The good ones are still offered; each bad
|
|
31
|
+
* one is named with its filename and its reason so it renders RED. Note that `profiles` can
|
|
32
|
+
* be EMPTY while `broken` is not — every descriptor on the machine is malformed. A consumer
|
|
33
|
+
* that tests only `profiles.length` renders that as the calm empty state above, which
|
|
34
|
+
* reports a failure as an emptiness. Check `broken.length` before drawing calm prose.
|
|
35
|
+
*
|
|
36
|
+
* { ok:false, absent:false, profiles:[], error:'…' } the directory could not be read.
|
|
37
|
+
* Permissions, or a FILE sitting where the directory should be. This is the distinction the
|
|
38
|
+
* module exists to protect: a missing directory and an unreadable one are different facts.
|
|
39
|
+
* Defaulting an unreadable directory to "no profiles configured" would state a capability
|
|
40
|
+
* claim the data does not support — it would tell the PM their machine has no profiles when
|
|
41
|
+
* what actually happened is that PMOS could not look.
|
|
42
|
+
*
|
|
43
|
+
* `dir` is returned in every case, including the failures, because R2 and R10 both require the
|
|
44
|
+
* surface to NAME the path it read or failed to read. An absence with no path is an assertion the
|
|
45
|
+
* PM cannot check.
|
|
46
|
+
*/
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
|
|
50
|
+
// Where the agent CLI keeps them. PMOS does not define this format and never writes here (PRD
|
|
51
|
+
// non-goal 3) — it is read-only to us, so an unexpected key is ignored rather than migrated.
|
|
52
|
+
const PROFILE_SUBDIR = ['.claude', 'profiles'];
|
|
53
|
+
|
|
54
|
+
function profilesDir(homeDir) { return path.join(homeDir, ...PROFILE_SUBDIR); }
|
|
55
|
+
|
|
56
|
+
// Errors are shown to a human who has to go fix a file, so carry the code AND the message. Both
|
|
57
|
+
// halves earn their place: the code says what kind of problem it is, the message says which path.
|
|
58
|
+
// Node already prefixes most fs messages with the code, so only prepend it when it is missing —
|
|
59
|
+
// "EISDIR: EISDIR: illegal operation" is noise in a line a PM is meant to act on.
|
|
60
|
+
function describeErr(e) {
|
|
61
|
+
const msg = String((e && e.message) || e);
|
|
62
|
+
const code = e && e.code ? String(e.code) : '';
|
|
63
|
+
return (code && !msg.startsWith(code) ? `${code}: ${msg}` : msg).slice(0, 200);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
67
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Validate one parsed descriptor against R1's table. Returns `{ profile }` or `{ error }`.
|
|
71
|
+
*
|
|
72
|
+
* The rule running through every branch: A KEY THAT IS PRESENT BUT WRONG-TYPED MAKES THE
|
|
73
|
+
* DESCRIPTOR UNREADABLE. It is never coerced and never dropped. The reason is that both softer
|
|
74
|
+
* options lie in the PM's favour. Coercing `mcp: "jira"` into `["jira"]` invents a declaration the
|
|
75
|
+
* file did not make; dropping it renders "no mcp servers declared in this profile" over a file
|
|
76
|
+
* that plainly declared one. Rejecting is loud, names the file, and leaves the other descriptors
|
|
77
|
+
* alone (R4) — the PM gets a fixable error instead of a quiet wrong answer.
|
|
78
|
+
*
|
|
79
|
+
* A key that is ABSENT is a different matter: R1 forbids substituting a default, so absence is
|
|
80
|
+
* carried through as `null` (account) or `[]` (mcp) for the consumer to state, not to fill in.
|
|
81
|
+
*/
|
|
82
|
+
function validate(raw, stem) {
|
|
83
|
+
if (!isPlainObject(raw)) {
|
|
84
|
+
const found = raw === null ? 'null' : Array.isArray(raw) ? 'an array' : typeof raw;
|
|
85
|
+
return { error: `is not a JSON object — found ${found}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// `id` is the identity the pane line, the session record and the descriptor path all cite, so it
|
|
89
|
+
// is the one key with no tolerated absence. Falling back to the filename stem would look
|
|
90
|
+
// harmless and be exactly the wrong thing: PMOS would be manufacturing the identity it then
|
|
91
|
+
// displays as read from the file, and a later rename would silently change which profile a
|
|
92
|
+
// session claims to have run under.
|
|
93
|
+
if (!has(raw, 'id')) {
|
|
94
|
+
return { error: 'declares no `id` — PMOS will not substitute the filename, because the id is the identity the pane and the session record cite' };
|
|
95
|
+
}
|
|
96
|
+
if (typeof raw.id !== 'string' || !raw.id) return { error: '`id` is present but is not a non-empty string' };
|
|
97
|
+
if (raw.id !== stem) {
|
|
98
|
+
return { error: `declares id "${raw.id}" but is filed as ${stem}.json — the descriptor's identity disagrees with its filename` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let label = raw.id; // R1: display name, falls back to `id` when absent
|
|
102
|
+
if (has(raw, 'label')) {
|
|
103
|
+
if (typeof raw.label !== 'string' || !raw.label.trim()) return { error: '`label` is present but is not a non-empty string' };
|
|
104
|
+
label = raw.label;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Displayed VERBATIM and always attributed to the file (R1). PMOS cannot verify an account
|
|
108
|
+
// string and does not try; if it is wrong on disk it is wrong on screen, which is why every
|
|
109
|
+
// surface that shows it also shows where it came from. Absent stays null — "not declared" is a
|
|
110
|
+
// thing the consumer can say, and is not the same as an empty account name.
|
|
111
|
+
let account = null;
|
|
112
|
+
if (has(raw, 'account')) {
|
|
113
|
+
if (typeof raw.account !== 'string') return { error: '`account` is present but is not a string' };
|
|
114
|
+
account = raw.account;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// R7: these are names a file declares, never servers PMOS reached. Absent and `[]` collapse to
|
|
118
|
+
// the same array on purpose — both mean "this descriptor declares no MCP servers", which is one
|
|
119
|
+
// fact with one honest phrasing. What must NOT collapse into it is a malformed `mcp`, above.
|
|
120
|
+
let mcp = [];
|
|
121
|
+
if (has(raw, 'mcp')) {
|
|
122
|
+
if (!Array.isArray(raw.mcp) || raw.mcp.some((x) => typeof x !== 'string')) {
|
|
123
|
+
return { error: '`mcp` is present but is not an array of server names' };
|
|
124
|
+
}
|
|
125
|
+
mcp = raw.mcp.slice();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The only key with a mechanical effect: it becomes the environment overlay at process creation
|
|
129
|
+
// (R3). `null` (key absent) and `{}` (declared empty) are kept DISTINCT here even though both
|
|
130
|
+
// apply nothing, because the reader's job is to report the file faithfully; profileLine() is
|
|
131
|
+
// where they converge, and it converges them on the sentence that says nothing was applied.
|
|
132
|
+
let env = null;
|
|
133
|
+
if (has(raw, 'env')) {
|
|
134
|
+
if (!isPlainObject(raw.env)) return { error: '`env` is present but is not an object of environment variables' };
|
|
135
|
+
for (const k of Object.keys(raw.env)) {
|
|
136
|
+
if (typeof raw.env[k] !== 'string') {
|
|
137
|
+
return { error: `\`env.${k}\` is not a string — PMOS will not coerce a value into an environment variable it was not given` };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
env = { ...raw.env };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { profile: { id: raw.id, label, account, mcp, env } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* Read every descriptor under `<homeDir>/.claude/profiles`.
|
|
148
|
+
*
|
|
149
|
+
* `homeDir` is a parameter rather than `os.homedir()` read inside, which is what makes this
|
|
150
|
+
* testable against real fixture directories instead of against a mock of the filesystem. A caller
|
|
151
|
+
* with no home directory gets a stated failure; guessing one would be the same class of invention
|
|
152
|
+
* this whole module refuses.
|
|
153
|
+
*/
|
|
154
|
+
function readProfiles(homeDir) {
|
|
155
|
+
if (typeof homeDir !== 'string' || !homeDir) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false, absent: false, dir: null, profiles: [], broken: [],
|
|
158
|
+
error: 'no home directory was given — PMOS will not guess where .claude/profiles lives',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const dir = profilesDir(homeDir);
|
|
162
|
+
|
|
163
|
+
let names;
|
|
164
|
+
try {
|
|
165
|
+
names = fs.readdirSync(dir);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// THE DISTINCTION THIS MODULE EXISTS FOR. ENOENT is "there is nothing here", which is calm and
|
|
168
|
+
// true. Everything else — EACCES, ENOTDIR, EIO — is "PMOS could not look", which is a failure
|
|
169
|
+
// and must read red. Reporting the second as the first would tell the PM their machine has no
|
|
170
|
+
// profiles configured when PMOS never got far enough to know.
|
|
171
|
+
if (e && e.code === 'ENOENT') return { ok: true, absent: true, dir, profiles: [], broken: [], error: null };
|
|
172
|
+
if (e && e.code === 'ENOTDIR') {
|
|
173
|
+
return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} is not a directory — a file is sitting where the profiles directory should be` };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, absent: false, dir, profiles: [], broken: [], error: `${dir} could not be read — ${describeErr(e)}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const profiles = [];
|
|
179
|
+
const broken = [];
|
|
180
|
+
// Sorted so the picker's chip order is stable across reads. Directory order is not.
|
|
181
|
+
for (const name of names.slice().sort()) {
|
|
182
|
+
if (!name.endsWith('.json')) continue; // not a descriptor at all — not a failed one either
|
|
183
|
+
const file = path.join(dir, name);
|
|
184
|
+
const stem = name.slice(0, -'.json'.length);
|
|
185
|
+
|
|
186
|
+
let text;
|
|
187
|
+
try {
|
|
188
|
+
text = fs.readFileSync(file, 'utf8');
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// Includes EISDIR: a DIRECTORY named `something.json`. Reported rather than skipped — it
|
|
191
|
+
// occupies the name a descriptor would have, so silently passing over it would leave the PM
|
|
192
|
+
// looking at a picker missing a chip with nothing anywhere saying why.
|
|
193
|
+
broken.push({ file: name, path: file, error: `could not be read — ${describeErr(e)}` });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = JSON.parse(text);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
broken.push({ file: name, path: file, error: `is not valid JSON — ${String((e && e.message) || e).slice(0, 140)}` });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const v = validate(raw, stem);
|
|
206
|
+
if (v.error) { broken.push({ file: name, path: file, error: v.error }); continue; }
|
|
207
|
+
profiles.push({ ...v.profile, path: file });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ok:true means THE DIRECTORY was read. It does not mean every file in it was. R4 requires those
|
|
211
|
+
// to be separable, so a malformed descriptor lands in `broken` and leaves its neighbours usable.
|
|
212
|
+
return { ok: true, absent: false, dir, profiles, broken, error: null };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/*
|
|
216
|
+
* The one line naming what a pane is running under — PRD R6, verbatim sentences.
|
|
217
|
+
*
|
|
218
|
+
* DIVERGENCE FROM THE DESIGN, recorded rather than glossed. `HANDOFF.md` §3 asks for
|
|
219
|
+
* `profile: claude-work · mcp: jira confluence slack gworkspace`, and `mcp: none` for the other
|
|
220
|
+
* case. This returns the PRD's R6/R7 wording instead, for two reasons the PRD states and the
|
|
221
|
+
* design does not address:
|
|
222
|
+
* - `mcp: none` reads as a checked fact about the world. PMOS has no MCP client and never
|
|
223
|
+
* contacted anything; the only thing it knows is what the descriptor declared. R7 forbids the
|
|
224
|
+
* phrase outright, and the honest replacement is "no mcp servers declared in this profile".
|
|
225
|
+
* - the design's line has no way to say "the profile declares no environment overlay", which is
|
|
226
|
+
* the case where the PM is most likely to believe a choice took effect when nothing did.
|
|
227
|
+
* If the literal design string is wanted it is a PM ruling, not an implementation detail.
|
|
228
|
+
*
|
|
229
|
+
* "applied N environment variables" IS A CLAIM ABOUT THE SPAWN, not about the file. This function
|
|
230
|
+
* is a formatter and cannot verify it. The caller must therefore only pass a descriptor for a
|
|
231
|
+
* session PMOS actually created with that descriptor's overlay (R3); rendering this line from a
|
|
232
|
+
* descriptor that was merely selected would state an application that never happened.
|
|
233
|
+
*
|
|
234
|
+
* The three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE, because they survive
|
|
235
|
+
* a JSON round-trip differently and a session record is JSON:
|
|
236
|
+
* undefined / key absent → "not recorded" (an orphan tmux pane, or a record persisted before
|
|
237
|
+
* this feature existed — inventing a profile for it
|
|
238
|
+
* would be a lie about the past)
|
|
239
|
+
* null → "none was chosen" (PMOS started it and recorded that nothing was
|
|
240
|
+
* picked; `null` is written explicitly so it does
|
|
241
|
+
* not decay into the case above)
|
|
242
|
+
* A bare id STRING is rejected on purpose: what was applied is a fact about the descriptor, so a
|
|
243
|
+
* caller holding only an id must resolve it through readProfiles() first (R9 — a respawn whose
|
|
244
|
+
* descriptor has since been deleted must not substitute another).
|
|
245
|
+
*/
|
|
246
|
+
function profileLine(profile, read) {
|
|
247
|
+
if (profile === undefined) return 'profile: not recorded — PMOS did not start this pane';
|
|
248
|
+
|
|
249
|
+
if (profile === null) {
|
|
250
|
+
// Only claim the machine has none configured when the read in hand actually says so. A read
|
|
251
|
+
// that failed, or one that found broken descriptors, does not support that sentence — the
|
|
252
|
+
// profiles may well be there and unreadable.
|
|
253
|
+
const noneConfigured = !!read && read.ok === true &&
|
|
254
|
+
(read.absent === true ||
|
|
255
|
+
((read.profiles || []).length === 0 && (read.broken || []).length === 0));
|
|
256
|
+
return noneConfigured
|
|
257
|
+
? 'no profile — none was chosen (no profiles are configured on this machine)'
|
|
258
|
+
: 'no profile — none was chosen for this session';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!isPlainObject(profile)) return 'profile: unreadable — the recorded value is not a profile descriptor';
|
|
262
|
+
|
|
263
|
+
// `label` is the declared display name (R1) but `id` is the traceable one: it is the descriptor's
|
|
264
|
+
// filename stem, so a reader can go check the file. Label wins where present because the spec
|
|
265
|
+
// says so; where it is absent the id carries the line.
|
|
266
|
+
const name = (typeof profile.label === 'string' && profile.label) ? profile.label
|
|
267
|
+
: (typeof profile.id === 'string' && profile.id) ? profile.id : null;
|
|
268
|
+
if (!name) return 'profile: unreadable — the recorded descriptor names no profile';
|
|
269
|
+
|
|
270
|
+
const n = isPlainObject(profile.env) ? Object.keys(profile.env).length : 0;
|
|
271
|
+
if (n === 0) {
|
|
272
|
+
// Covers both "no `env` key" and "`env: {}`". They differ on disk and are identical to the PM:
|
|
273
|
+
// nothing was applied. Saying "applied 0 environment variables" would be the precise phrasing
|
|
274
|
+
// most likely to be read as success, so R6 replaces it with a sentence about consequence.
|
|
275
|
+
return `profile: ${name} — the profile declares no environment overlay; this session runs with the workspace's default environment`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const vars = `applied ${n} environment variable${n === 1 ? '' : 's'}`;
|
|
279
|
+
const mcp = (Array.isArray(profile.mcp) && profile.mcp.length)
|
|
280
|
+
? `mcp declared by the profile: ${profile.mcp.join(' ')}`
|
|
281
|
+
: 'no mcp servers declared in this profile';
|
|
282
|
+
return `profile: ${name} — ${vars} · ${mcp}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { readProfiles, profileLine, profilesDir, PROFILE_SUBDIR };
|