create-pmos 0.38.0 → 0.38.1
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/bin/create-pmos.js +27 -6
- 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/bin/pmos.js +23 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/modes-live.js +71 -5
- package/workspace/public/pm/modes.js +1 -1
- package/workspace/server.js +47 -3
package/bin/create-pmos.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* is unchanged. The workspace app itself is bundled in this package, not downloaded.
|
|
19
19
|
*/
|
|
20
20
|
'use strict';
|
|
21
|
+
const os = require('os');
|
|
21
22
|
const fs = require('fs');
|
|
22
23
|
const path = require('path');
|
|
23
24
|
const { spawnSync } = require('child_process');
|
|
@@ -193,13 +194,33 @@ function installedWorkspaceVersion() {
|
|
|
193
194
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
194
195
|
const r = spawnSync(npmCmd, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' });
|
|
195
196
|
if (r.status !== 0 || !r.stdout) return null;
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
const dir = path.join(r.stdout.trim(), 'pmos-workspace');
|
|
198
|
+
try {
|
|
199
|
+
// A SYMLINK is not an install. `npm install -g <folder>` links to the folder and installs no
|
|
200
|
+
// dependencies, so the link reads as the bundled version (it IS the bundled folder, here the npx
|
|
201
|
+
// cache) while @xterm and node-pty are absent. Report it as unusable so it is replaced by a copy.
|
|
202
|
+
if (fs.lstatSync(dir).isSymbolicLink()) return null;
|
|
203
|
+
// A real copy must also carry its dependencies, or the terminal page cannot load.
|
|
204
|
+
if (!fs.existsSync(path.join(dir, 'node_modules', '@xterm', 'xterm', 'package.json'))) return null;
|
|
205
|
+
return require(path.join(dir, 'package.json')).version || null;
|
|
206
|
+
} catch { return null; }
|
|
198
207
|
}
|
|
208
|
+
// Install a COPY, never a link: pack the bundled app and install the tarball. A tarball install
|
|
209
|
+
// unpacks into the global root and installs the app's own dependencies there (builds node-pty once).
|
|
199
210
|
function installWorkspaceGlobal() {
|
|
200
211
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
const shell = process.platform === 'win32';
|
|
213
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-ws-'));
|
|
214
|
+
try {
|
|
215
|
+
const pack = spawnSync(npmCmd, ['pack', WORKSPACE, '--pack-destination', tmp, '--silent'],
|
|
216
|
+
{ encoding: 'utf8', shell });
|
|
217
|
+
const tgz = pack.status === 0 ? (pack.stdout || '').trim().split('\n').pop() : '';
|
|
218
|
+
if (!tgz) { console.error(`create-pmos: could not pack the workspace app${pack.stderr ? ' — ' + pack.stderr.trim() : ''}`); return 1; }
|
|
219
|
+
return spawnSync(npmCmd, ['install', '-g', path.join(tmp, tgz), '--no-audit', '--no-fund'],
|
|
220
|
+
{ stdio: 'inherit', shell }).status;
|
|
221
|
+
} finally {
|
|
222
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
223
|
+
}
|
|
203
224
|
}
|
|
204
225
|
// A persistent server started by the OLD code keeps serving OLD assets after an upgrade — stop it so
|
|
205
226
|
// the next launch brings the server up on the freshly installed code. Best-effort; ignore failures.
|
|
@@ -251,8 +272,8 @@ function workspace(targetArg, opts) {
|
|
|
251
272
|
// Global install refused (common on system-owned npm prefixes) — don't fail, still open the GUI.
|
|
252
273
|
console.log('\n⚠ Could not put `pmos` on your PATH automatically (your npm global prefix may be locked down).');
|
|
253
274
|
console.log(' To get the bare `pmos` command for next time, run ONE of:');
|
|
254
|
-
console.log(` npm
|
|
255
|
-
console.log(` sudo npm install -g
|
|
275
|
+
console.log(` npm pack "${WORKSPACE}" && npm install -g ./pmos-workspace-*.tgz # then: pmos`);
|
|
276
|
+
console.log(` sudo npm install -g ./pmos-workspace-*.tgz # if your node is system-owned`);
|
|
256
277
|
console.log(` Docs: npm prefix without sudo → https://docs.npmjs.com/resolving-eacces-permissions-errors`);
|
|
257
278
|
console.log(' Opening the GUI now from the bundled copy…\n');
|
|
258
279
|
process.exit(launchWorkspace(targetDir, false));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.1",
|
|
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-02T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-02T01:57:56Z"}
|
|
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-02T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-02T01:57:56Z"}
|
|
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-02T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-02T01:57:56Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -43,6 +43,7 @@ const PINNED = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : nul
|
|
|
43
43
|
// this one's pid file — that is exactly how `pmos stop` was left unable to find the real server.
|
|
44
44
|
const pidFile = (port) => path.join(STATE, D.pidFileName(port));
|
|
45
45
|
const logFile = (port) => path.join(STATE, `server-${port}.log`);
|
|
46
|
+
const APP_VERSION = (() => { try { return require('../package.json').version || null; } catch { return null; } })();
|
|
46
47
|
const urlOf = (port) => `http://127.0.0.1:${port}`;
|
|
47
48
|
const NO_OPEN = process.env.PMOS_NO_OPEN === '1'; // tests set this
|
|
48
49
|
|
|
@@ -85,7 +86,7 @@ async function discover() {
|
|
|
85
86
|
const ports = D.candidatePorts({ stateDir: STATE, pinned: PINNED });
|
|
86
87
|
const hits = await Promise.all(ports.map(async (port) => {
|
|
87
88
|
const s = await probe(port);
|
|
88
|
-
return s ? { port, sessions: s.sessions } : null;
|
|
89
|
+
return s ? { port, sessions: s.sessions, version: s.version || null } : null;
|
|
89
90
|
}));
|
|
90
91
|
return hits.filter(Boolean);
|
|
91
92
|
}
|
|
@@ -160,10 +161,19 @@ function fixSpawnHelperPerms() {
|
|
|
160
161
|
try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
|
|
161
162
|
}
|
|
162
163
|
}
|
|
164
|
+
// Every runtime dependency must RESOLVE from this app's own directory — not just node-pty. A copy
|
|
165
|
+
// with node-pty but no @xterm (or no node_modules at all) starts a server whose terminal page cannot
|
|
166
|
+
// load, and says nothing until the PM clicks a session.
|
|
167
|
+
function missingDeps() {
|
|
168
|
+
let deps = [];
|
|
169
|
+
try { deps = Object.keys(require('../package.json').dependencies || {}); } catch {}
|
|
170
|
+
return deps.filter((d) => !fs.existsSync(path.join(WS, 'node_modules', ...d.split('/'), 'package.json')));
|
|
171
|
+
}
|
|
163
172
|
function ensureInstalled() {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
const missing = missingDeps();
|
|
174
|
+
if (missing.length) {
|
|
175
|
+
say(`First run — installing the workspace (one-time, ~20s)… missing: ${missing.join(', ')}`);
|
|
176
|
+
if (!npm(['install', '--omit=dev', '--no-audit', '--no-fund']) || missingDeps().length) {
|
|
167
177
|
say('✗ install failed. Run `pmos doctor` for the exact fix.');
|
|
168
178
|
return false;
|
|
169
179
|
}
|
|
@@ -213,6 +223,15 @@ async function choosePort() {
|
|
|
213
223
|
async function up(dir) {
|
|
214
224
|
// 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
|
|
215
225
|
let live = await discover();
|
|
226
|
+
// A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
|
|
227
|
+
// it is the failure behind "not found is not valid JSON": the page asks for routes that process
|
|
228
|
+
// never learned. Sessions are tmux-backed, so replacing the server does not end them.
|
|
229
|
+
const stale = live.filter((l) => l.version !== APP_VERSION);
|
|
230
|
+
if (stale.length) {
|
|
231
|
+
say(`note: the running workspace is ${stale[0].version ? 'version ' + stale[0].version : 'an older version'}; this install is ${APP_VERSION}. Restarting it on the new code — sessions keep running in tmux.`);
|
|
232
|
+
for (const s of stale) await stopOne(s.port);
|
|
233
|
+
live = await discover();
|
|
234
|
+
}
|
|
216
235
|
if (live.length) {
|
|
217
236
|
const port = live[0].port;
|
|
218
237
|
if (live.length > 1) {
|
package/workspace/package.json
CHANGED
|
@@ -31,7 +31,7 @@ function read(key, url) {
|
|
|
31
31
|
if (!hit || hit.state === 'ok') {
|
|
32
32
|
const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, at: hit ? hit.at : 0 };
|
|
33
33
|
cache.set(key, next);
|
|
34
|
-
fetch(url).then(
|
|
34
|
+
fetch(url).then(readJson).then(
|
|
35
35
|
(data) => { cache.set(key, { state: data && data.ok === false ? 'error' : 'ok', data, error: data && data.error, at: Date.now() }); setState({}); },
|
|
36
36
|
(e) => { cache.set(key, { state: 'error', error: (e && e.message) || String(e), at: Date.now() }); setState({}); },
|
|
37
37
|
);
|
|
@@ -40,8 +40,20 @@ function read(key, url) {
|
|
|
40
40
|
return hit;
|
|
41
41
|
}
|
|
42
42
|
const reload = (key) => cache.delete(key);
|
|
43
|
+
/* A route this server does not have answers 404 with the plain text `not found`. Parsing that as JSON
|
|
44
|
+
* showed the PM "Unexpected token 'o', \"not found\" is not valid JSON" — true, and no help. The usual
|
|
45
|
+
* cause is a server still running code older than the page, so say that and the one command. */
|
|
46
|
+
function readJson(r) {
|
|
47
|
+
return r.text().then((t) => {
|
|
48
|
+
try { return JSON.parse(t); } catch {
|
|
49
|
+
throw new Error(r.status === 404
|
|
50
|
+
? 'this server does not have that route — it is probably running an older version than the page. Run `pmos` again to restart it'
|
|
51
|
+
: 'the server answered ' + r.status + ' with something that is not JSON');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
43
55
|
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
44
|
-
.then(
|
|
56
|
+
.then(readJson).catch((e) => ({ ok: false, error: (e && e.message) || String(e) }));
|
|
45
57
|
function status(r, what) {
|
|
46
58
|
if (r.state === 'loading' && !r.data) return el('div', { style: NOTE }, iv('reading ' + what + ' …'));
|
|
47
59
|
if (r.state === 'error') return el('div', { style: NOTE + ';color:var(--destructive)' }, iv(what + ' could not be read — ' + (r.error || 'error')));
|
|
@@ -321,19 +333,73 @@ function candidatesBlock(wsId) {
|
|
|
321
333
|
}
|
|
322
334
|
return kids;
|
|
323
335
|
}
|
|
336
|
+
/* Discovery is its own section at the TOP: it used to sit under the whole skill list, below the fold,
|
|
337
|
+
* so a PM who scrolled nothing saw no discovery at all. */
|
|
338
|
+
function discoveryBlock(wsId) {
|
|
339
|
+
const sch = read('schedules', '/api/schedules?all=1');
|
|
340
|
+
const mine = ((sch.data && sch.data.schedules) || []).find((x) => x.skill === 'skill-discover' || x.task === 'skill-discover');
|
|
341
|
+
const kids = [el('div', { style: LABEL + ';color:var(--foreground)', 'data-r13': 'discovery-label' }, iv('skill discovery')),
|
|
342
|
+
el('div', { style: NOTE }, iv('Finds skills in your own repository and from sources you name. Nothing it finds runs until you read it and approve it.')),
|
|
343
|
+
el('div', { style: 'display:flex;gap:4px;flex-wrap:wrap;padding:0 14px 6px' },
|
|
344
|
+
mine ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'discover-now', onClick: async () => { await post('/api/schedules/run', { id: mine.id }); reload('schedules'); setState({}); } }, 'find skills now') : null,
|
|
345
|
+
mine ? el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);align-self:center' }, iv('weekly · next ' + (mine.next ? String(mine.next).slice(0, 16).replace('T', ' ') : '—')))
|
|
346
|
+
: seedRow('skills', 'set up weekly skill discovery', 'schedules the skill-discover skill weekly (Wednesday 09:00) for the selected repository; it files candidates for you to read and never approves them'))];
|
|
347
|
+
return [...kids, ...candidatesBlock(wsId)];
|
|
348
|
+
}
|
|
324
349
|
export function skillsLive() {
|
|
325
350
|
const wsId = dataMod && state.product ? dataMod.wsOf(state.product) : null;
|
|
326
351
|
const r = read('skills:' + (wsId || ''), '/api/skills' + (wsId ? '?ws=' + encodeURIComponent(wsId) : ''));
|
|
327
352
|
const st = status(r, 'the skills');
|
|
328
|
-
|
|
353
|
+
const head = discoveryBlock(wsId);
|
|
354
|
+
if (st) return [el('div', { style: 'padding:6px 0' }, [...head, st])];
|
|
329
355
|
const list = (r.data && r.data.skills) || [];
|
|
330
|
-
const
|
|
356
|
+
const open = state.detail && state.detail.kind === 'skill' ? state.detail.id : null;
|
|
357
|
+
const kids = list.map((sk) => el('button', { style: ROW + (open === sk.id ? ';background:var(--accent)' : ''), hover: 'background:var(--accent)', 'data-r4': 'skill', 'data-id': sk.id,
|
|
358
|
+
onClick: () => setState({ detail: { kind: 'skill', id: sk.id } }) },
|
|
331
359
|
el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
|
|
332
360
|
el('span', { style: 'font-family:var(--mono);font-size:12px' }, iv(sk.label || sk.skill || sk.id)),
|
|
333
361
|
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(sk.source))),
|
|
334
362
|
el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground)' }, iv(sk.description))));
|
|
335
363
|
if (!list.length) kids.push(el('div', { style: NOTE }, iv('No skills — a successful read of nothing.')));
|
|
336
|
-
return [el('div', { style: 'padding:6px 0' }, [...
|
|
364
|
+
return [el('div', { style: 'padding:6px 0' }, [...head, el('div', { style: LABEL + ';color:var(--foreground)' }, iv('skills · ' + list.length + ' · click one to read it')), ...kids])];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* The detail of one skill (panel 3). The text is read from the server on open. Your own skills are
|
|
368
|
+
* editable and save in place; a builtin or an approved discovered skill is read-only, and "make my own
|
|
369
|
+
* copy" writes an editable one beside it, so the original is never changed under a review. */
|
|
370
|
+
let skillDraft = { id: null, text: null }; let skillNote = null;
|
|
371
|
+
export function skillDetailLive(det) {
|
|
372
|
+
const id = det && det.id;
|
|
373
|
+
const wsId = dataMod && state.product ? dataMod.wsOf(state.product) : null;
|
|
374
|
+
const r = read('skill:' + id, '/api/skills/read?id=' + encodeURIComponent(id || '') + (wsId ? '&ws=' + encodeURIComponent(wsId) : ''));
|
|
375
|
+
const st = status(r, 'this skill');
|
|
376
|
+
if (st) return [st];
|
|
377
|
+
const d = r.data;
|
|
378
|
+
if (skillDraft.id !== id) skillDraft = { id, text: null };
|
|
379
|
+
const text = skillDraft.text !== null ? skillDraft.text : d.text;
|
|
380
|
+
const dirty = skillDraft.text !== null && skillDraft.text !== d.text;
|
|
381
|
+
const out = [el('div', { style: 'padding:14px 14px 6px' },
|
|
382
|
+
el('div', { style: 'font-family:var(--mono);font-size:13px' }, iv(d.label)),
|
|
383
|
+
el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);margin-top:2px' }, iv(d.source + (d.editable ? ' · yours — edit it below' : ' · read-only') + ' · ' + d.file)),
|
|
384
|
+
el('div', { style: 'font-size:12px;margin-top:6px' }, iv(d.description)),
|
|
385
|
+
(d.output || []).length ? el('div', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:6px' }, iv('always produces: ' + d.output.join(' · '))) : null)];
|
|
386
|
+
out.push(el('div', { style: 'padding:6px 14px' }, el('textarea', { 'data-r13': 'skill-text', spellcheck: 'false', readonly: d.editable ? null : 'readonly',
|
|
387
|
+
style: 'width:100%;min-height:340px;font-family:var(--mono);font-size:11.5px;line-height:1.5;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);padding:8px;resize:vertical',
|
|
388
|
+
onInput: (e) => { skillDraft = { id, text: e.target.value }; } }, text)));
|
|
389
|
+
out.push(el('div', { style: 'display:flex;gap:6px;align-items:center;padding:6px 14px 14px;flex-wrap:wrap' },
|
|
390
|
+
d.editable ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'skill-save', onClick: async () => {
|
|
391
|
+
const res = await post('/api/skills/save', { id, text: skillDraft.text !== null ? skillDraft.text : d.text });
|
|
392
|
+
skillNote = res && res.ok ? 'saved' : ((res && res.error) || 'not saved');
|
|
393
|
+
if (res && res.ok) { skillDraft = { id, text: null }; reload('skill:' + id); reload('skills:' + (wsId || '')); }
|
|
394
|
+
setState({});
|
|
395
|
+
} }, 'save') : el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r13': 'skill-copy', onClick: async () => {
|
|
396
|
+
const res = await post('/api/skills/copy', { id, ws: wsId });
|
|
397
|
+
if (res && res.ok) { skillNote = 'copied as ' + res.id + ' — it is yours to edit'; reload('skills:' + (wsId || '')); setState({ detail: { kind: 'skill', id: res.id } }); }
|
|
398
|
+
else { skillNote = (res && res.error) || 'not copied'; setState({}); }
|
|
399
|
+
} }, 'make my own copy to edit'),
|
|
400
|
+
dirty ? el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, 'unsaved changes') : null,
|
|
401
|
+
skillNote ? el('span', { 'data-r13': 'skill-note', style: 'font-size:11px;color:var(--muted-foreground)' }, iv(skillNote)) : null));
|
|
402
|
+
return out;
|
|
337
403
|
}
|
|
338
404
|
|
|
339
405
|
/* ── knowledge ──────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -1052,7 +1052,7 @@ const LISTS = {
|
|
|
1052
1052
|
const BODIES = {
|
|
1053
1053
|
flow: flowBody,
|
|
1054
1054
|
sched: schedBody,
|
|
1055
|
-
skill: skillBody,
|
|
1055
|
+
skill: (det) => (FIXTURE ? skillBody(det) : LIVE.skillDetailLive(det)),
|
|
1056
1056
|
know: knowBody,
|
|
1057
1057
|
agent: agentBody,
|
|
1058
1058
|
mem: memBody,
|
package/workspace/server.js
CHANGED
|
@@ -116,6 +116,7 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
|
|
|
116
116
|
const PUB = path.join(__dirname, 'public');
|
|
117
117
|
const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
|
|
118
118
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
119
|
+
const APP_VERSION = (() => { try { return require('./package.json').version || null; } catch { return null; } })();
|
|
119
120
|
// ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
|
|
120
121
|
// It is NOT `workspace/state/` any more. That directory is inside the application directory the
|
|
121
122
|
// test suite runs from, which put the PM's live sessions, workspace registry and authored skills
|
|
@@ -1983,14 +1984,17 @@ function filingContract(w) {
|
|
|
1983
1984
|
// D76 — `root` adds the repository's APPROVED discovered skills, loaded FIRST so the PM's own skill of the same id
|
|
1984
1985
|
// overrides one and a built-in overrides both. Only an approved candidate whose file still hashes to what was approved
|
|
1985
1986
|
// is ever offered; its harness is forced to an agent skill running claude.
|
|
1986
|
-
|
|
1987
|
+
// The loaded skill records, each with the `file` it came from. loadTasks maps these to the task shape
|
|
1988
|
+
// and drops the file; the skill detail routes below need the file, so both read through here.
|
|
1989
|
+
function loadSkillRecords(root) {
|
|
1987
1990
|
const discovered = root ? skillsReg.loadApproved(root, SKILL_APPROVALS, skillStore.parseSkill).skills : [];
|
|
1988
1991
|
return skillStore.loadSkills([
|
|
1989
1992
|
...(discovered.length ? [{ skills: discovered }] : []),
|
|
1990
1993
|
{ dir: userSkillsDir(), source: 'user' },
|
|
1991
1994
|
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
1992
|
-
])
|
|
1995
|
+
]);
|
|
1993
1996
|
}
|
|
1997
|
+
function loadTasks(root) { return loadSkillRecords(root).map(toTask); }
|
|
1994
1998
|
// Map a loaded skill to the internal task shape fireSchedule/the picker use. `kind` is derived from the
|
|
1995
1999
|
// file's `run` and stays INTERNAL — it is never a field the PM selects.
|
|
1996
2000
|
function toTask(s) {
|
|
@@ -4590,6 +4594,44 @@ async function handleRequest(req, res) {
|
|
|
4590
4594
|
}
|
|
4591
4595
|
if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
|
|
4592
4596
|
// ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
|
|
4597
|
+
// One skill's full text, for the detail panel. Only the PM's own skills (source `user`) are
|
|
4598
|
+
// editable: a builtin ships with the app and a discovered one is pinned to the text the PM approved,
|
|
4599
|
+
// so editing either in place would change what was reviewed. Both can be copied to an editable one.
|
|
4600
|
+
if (p === '/api/skills/read' && req.method === 'GET') {
|
|
4601
|
+
const sw = getWs(url.searchParams.get('ws'));
|
|
4602
|
+
const rec = loadSkillRecords(sw ? sw.root : null).find((x) => x.id === url.searchParams.get('id'));
|
|
4603
|
+
if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
|
|
4604
|
+
let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
|
|
4605
|
+
return json(res, { ok: true, id: rec.id, label: rec.label || rec.id, source: rec.source, editable: rec.source === 'user',
|
|
4606
|
+
file: path.basename(rec.file), description: rec.description || '', output: rec.output || [], text });
|
|
4607
|
+
}
|
|
4608
|
+
if (p === '/api/skills/save' && req.method === 'POST') {
|
|
4609
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4610
|
+
const rec = loadSkillRecords(null).find((x) => x.id === body.id && x.source === 'user');
|
|
4611
|
+
if (!rec) return json(res, { ok: false, error: 'only your own skills can be edited here' }, 403);
|
|
4612
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
4613
|
+
if (!text.trim() || text.length > 200000) return json(res, { ok: false, error: 'the skill text is empty or too long' }, 400);
|
|
4614
|
+
const parsed = skillStore.parseSkill(text);
|
|
4615
|
+
if (parsed.name !== rec.id) return json(res, { ok: false, error: `the name: line must stay "${rec.id}" — rename by copying instead` }, 400);
|
|
4616
|
+
if (!String(parsed.description || '').trim()) return json(res, { ok: false, error: 'the skill needs a description: line' }, 400);
|
|
4617
|
+
try { const tmp = rec.file + '.tmp'; fs.writeFileSync(tmp, text.endsWith('\n') ? text : text + '\n'); fs.renameSync(tmp, rec.file); }
|
|
4618
|
+
catch { return json(res, { ok: false, error: 'the skill file could not be written' }, 500); }
|
|
4619
|
+
return json(res, { ok: true, id: rec.id });
|
|
4620
|
+
}
|
|
4621
|
+
if (p === '/api/skills/copy' && req.method === 'POST') {
|
|
4622
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4623
|
+
const sw = getWs(body.ws);
|
|
4624
|
+
const all = loadSkillRecords(sw ? sw.root : null);
|
|
4625
|
+
const rec = all.find((x) => x.id === body.id);
|
|
4626
|
+
if (!rec) return json(res, { ok: false, error: 'no skill with that name' }, 404);
|
|
4627
|
+
let id = rec.id + '-copy';
|
|
4628
|
+
for (let n = 2; all.some((x) => x.id === id); n++) id = rec.id + '-copy-' + n;
|
|
4629
|
+
let text; try { text = fs.readFileSync(rec.file, 'utf8'); } catch { return json(res, { ok: false, error: 'the skill file could not be read' }, 500); }
|
|
4630
|
+
text = text.replace(/^name:.*$/m, 'name: ' + id).replace(/^label:\s*"?(.*?)"?\s*$/m, (_, l) => 'label: "' + l.replace(/"/g, "'") + ' (my copy)"');
|
|
4631
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, id + '.skill'), text, { flag: 'wx' }); }
|
|
4632
|
+
catch { return json(res, { ok: false, error: 'the copy could not be written' }, 500); }
|
|
4633
|
+
return json(res, { ok: true, id });
|
|
4634
|
+
}
|
|
4593
4635
|
// GET lists every skill with NO kind field — from the user's view there is just a skill.
|
|
4594
4636
|
if (p === '/api/skills' && req.method === 'GET') {
|
|
4595
4637
|
const sw = getWs(url.searchParams.get('ws'));
|
|
@@ -4810,7 +4852,9 @@ async function handleRequest(req, res) {
|
|
|
4810
4852
|
if (mc.ok) mc.core.logDecision(s.root, { kind: 'switch', session: s.id, agent: s.agent, tier: r.tier, model: r.model, by: 'human:wawan' });
|
|
4811
4853
|
return json(res, { ok: true, model: r.model, tier: r.tier, note: 'typed /model into the pane; effort is unchanged' });
|
|
4812
4854
|
}
|
|
4813
|
-
|
|
4855
|
+
// `version` lets the launcher notice a server still running OLD code after an upgrade: files on disk
|
|
4856
|
+
// advance under a live process, so the UI would otherwise call routes that process never learned.
|
|
4857
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION });
|
|
4814
4858
|
if (p === '/api/git/branches') {
|
|
4815
4859
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
4816
4860
|
// branch). Fixed git args, no caller input in the read path (R1).
|