create-pmos 0.26.0 → 0.27.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 +2 -2
- package/workspace/README.md +17 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +63 -0
- package/workspace/public/pm/panel-initiatives.js +8 -1
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +174 -20
- package/workspace/test-run.js +84 -0
package/workspace/server.js
CHANGED
|
@@ -111,7 +111,96 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
|
|
|
111
111
|
const PUB = path.join(__dirname, 'public');
|
|
112
112
|
const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
|
|
113
113
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
114
|
-
|
|
114
|
+
// ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
|
|
115
|
+
// It is NOT `workspace/state/` any more. That directory is inside the application directory the
|
|
116
|
+
// test suite runs from, which put the PM's live sessions, workspace registry and authored skills
|
|
117
|
+
// within reach of every test, every spawned child and every stray shell command — and three rounds
|
|
118
|
+
// of guarding destructive verbs failed, each to a verb nobody had guarded yet. Unreachable beats
|
|
119
|
+
// guarded. STATE now resolves to a per-user location (PMOS_STATE_DIR wins; else the OS default),
|
|
120
|
+
// and an existing in-repo state/ is MOVED there once, idempotently, on first run.
|
|
121
|
+
const stateRoot = require('./lib/state-root');
|
|
122
|
+
// LEGACY_STATE is what `workspace/state/` used to be, kept for exactly two jobs: it is the source
|
|
123
|
+
// the migration moves FROM, and it is D64's file-mode mirror for an EMBEDDED PMOS-self checkout
|
|
124
|
+
// that has no `pmos/state/` yet (see stateDirFor). The system of record stays in the repository
|
|
125
|
+
// that earned it — only the churny runtime moved out.
|
|
126
|
+
const LEGACY_STATE = path.join(__dirname, 'state');
|
|
127
|
+
// ── REQUIRING THIS FILE IS NOT A QUIET ACT. ───────────────────────────────────────────────────
|
|
128
|
+
// `require('./server.js')` — which two suites do, for the exported helpers — runs everything at
|
|
129
|
+
// module scope: it resolves the state root, registers the home workspace, restores persisted
|
|
130
|
+
// sessions and spawns a first shell. Unconfigured, all of that now lands in the PM's REAL per-user
|
|
131
|
+
// state, and it did exactly that once while this change was being written. So a library consumer
|
|
132
|
+
// must name its own state directory, and finds out by being told rather than by contaminating the
|
|
133
|
+
// PM's registry and discovering it a day later.
|
|
134
|
+
const REQUIRED_AS_LIBRARY = require.main !== module;
|
|
135
|
+
if (REQUIRED_AS_LIBRARY && !stateRoot.overrideDir(process.env)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'server.js was required as a library with no PMOS_STATE_DIR set. Requiring it runs its module ' +
|
|
138
|
+
'scope — resolving the runtime state root, registering the home workspace and spawning a shell ' +
|
|
139
|
+
'— which would write into the PM\'s real runtime state. Point PMOS_STATE_DIR at a scratch ' +
|
|
140
|
+
'directory before the require (test-run.js#configure does this).');
|
|
141
|
+
}
|
|
142
|
+
// The migration is likewise offered ONLY to a real server start. Relocating the PM's live sessions
|
|
143
|
+
// as a side effect of an import is not a thing an import may do, whatever its state directory.
|
|
144
|
+
const STATE_RESOLVED = stateRoot.resolveStateRoot({
|
|
145
|
+
env: process.env,
|
|
146
|
+
legacy: REQUIRED_AS_LIBRARY ? null : LEGACY_STATE,
|
|
147
|
+
});
|
|
148
|
+
const STATE = STATE_RESOLVED.root;
|
|
149
|
+
// ── AN UNSETTLED STATE ROOT MUST NOT DEGRADE INTO "YOU HAVE NOTHING". ─────────────────────────
|
|
150
|
+
// When both directories hold state nothing is moved (only a human can decide which sixteen
|
|
151
|
+
// sessions are the real sixteen) — but the stores the per-user root does NOT hold used to resolve
|
|
152
|
+
// to it anyway, so the PM's eleven skills and forty-five schedules rendered as two empty lists and
|
|
153
|
+
// forty-five scheduled runs silently stopped firing. Per store: fall back to the legacy root ONLY
|
|
154
|
+
// where the per-user root holds no copy at all, so there is exactly one copy and reading/writing it
|
|
155
|
+
// cannot fork anything. See lib/state-root.js#resolveStore for the whole argument, including why a
|
|
156
|
+
// test run can never reach this path.
|
|
157
|
+
//
|
|
158
|
+
// This applies to EVERY outcome that is not a completed move, not only to a conflict. A move the
|
|
159
|
+
// filesystem REFUSES leaves the PM's stores in the legacy directory just as a conflict does, and
|
|
160
|
+
// for one round it resolved them to the per-user root anyway and said nothing at all — the same
|
|
161
|
+
// empty list with the stderr line removed as well. state-root.js#isSettled is the single predicate.
|
|
162
|
+
//
|
|
163
|
+
// RESOLVED PER CALL, NOT ONCE AT BOOT. The previous round pinned these to constants, which meant
|
|
164
|
+
// the process kept whatever it decided at startup: a PM who resolved the conflict — or fixed the
|
|
165
|
+
// permission — went on being served the old directory until they restarted, and a PM who moved a
|
|
166
|
+
// store INTO the per-user root had two live copies with the server writing the stale one. When the
|
|
167
|
+
// root is settled these functions short-circuit before touching the filesystem (the case every test
|
|
168
|
+
// run and every normal boot is in), so the cost is a path.join; only an unsettled root pays for two
|
|
169
|
+
// existsSync calls, which is what the next boot would do anyway.
|
|
170
|
+
function schedStore() {
|
|
171
|
+
return stateRoot.resolveStore({
|
|
172
|
+
root: STATE, legacy: LEGACY_STATE, name: 'schedules.json', migration: STATE_RESOLVED.migration });
|
|
173
|
+
}
|
|
174
|
+
function skillsStore() {
|
|
175
|
+
return stateRoot.resolveStore({
|
|
176
|
+
root: STATE, legacy: LEGACY_STATE, name: 'skills', migration: STATE_RESOLVED.migration });
|
|
177
|
+
}
|
|
178
|
+
// The ONE fact every surface repeats. `{ok:true}` normally; otherwise a discriminated failure naming
|
|
179
|
+
// BOTH directories — and, for a refused move, the errno and the plain-language cause, because that
|
|
180
|
+
// is the one kind the PM cannot resolve from the directory names alone. Carried on /api/workspaces,
|
|
181
|
+
// /api/skills, /api/schedules and every /api/console payload, and written into the first line of
|
|
182
|
+
// every new terminal pane. stderr alone was not a surface: `pmos` detaches it into a log nobody reads.
|
|
183
|
+
function stateHealthNow() {
|
|
184
|
+
const sk = skillsStore(), sc = schedStore();
|
|
185
|
+
return stateRoot.stateHealth({
|
|
186
|
+
migration: STATE_RESOLVED.migration,
|
|
187
|
+
fellBack: [sk.fellBack ? 'skills' : null, sc.fellBack ? 'schedules.json' : null].filter(Boolean),
|
|
188
|
+
// If the state root itself could not be created there is nowhere to read from, every list
|
|
189
|
+
// renders empty and the migration record says nothing about it — resolveStateRoot used to
|
|
190
|
+
// swallow that mkdir on the promise that "the caller's first write will report it". The caller
|
|
191
|
+
// is a store, and what a store does is return []. So the error is carried here instead.
|
|
192
|
+
rootError: STATE_RESOLVED.rootError || null,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (STATE_RESOLVED.migration.action === 'moved') {
|
|
196
|
+
console.log(`· moved the workspace's runtime state out of the app directory → ${STATE}`);
|
|
197
|
+
}
|
|
198
|
+
// NOT an `else if`. Chaining it off the 'moved' branch is how 'failed' came to print nothing at all:
|
|
199
|
+
// every non-ok health is reported here on its own terms, whatever the action was.
|
|
200
|
+
{
|
|
201
|
+
const boot = stateHealthNow();
|
|
202
|
+
if (!boot.ok) console.error('✗ ' + boot.error);
|
|
203
|
+
}
|
|
115
204
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
116
205
|
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
117
206
|
const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
|
|
@@ -402,6 +491,20 @@ function treePeersDescribed(tree, excludeId) {
|
|
|
402
491
|
function treeWriter(tree) {
|
|
403
492
|
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
404
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* THE UNMIGRATED STATE, SAID WHERE THE PM IS LOOKING.
|
|
496
|
+
*
|
|
497
|
+
* A conflict already printed to stderr — and `pmos` detaches stderr into a log nobody reads, so in
|
|
498
|
+
* practice it printed to nobody while the PM's skills and schedules quietly rendered as two empty
|
|
499
|
+
* lists. This puts the same sentence in the pane's own scrollback, which is a surface the PM is by
|
|
500
|
+
* definition looking at and which persists in the session record. Every new session, until the two
|
|
501
|
+
* directories are resolved, because a notice that shows once is a notice that is missed once.
|
|
502
|
+
*/
|
|
503
|
+
function announceStateHealth(s) {
|
|
504
|
+
const h = stateHealthNow();
|
|
505
|
+
if (h.ok) return;
|
|
506
|
+
appendBuf(s, `\x1b[38;5;203m✗ ${h.error}\x1b[0m\r\n`);
|
|
507
|
+
}
|
|
405
508
|
/**
|
|
406
509
|
* A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
|
|
407
510
|
* found into its own scrollback. The pane line is deliberately part of the session's record rather
|
|
@@ -1355,19 +1458,40 @@ function buildSurface(name, root, w) {
|
|
|
1355
1458
|
// (D62). v1 tasks are DETERMINISTIC (generated from a surface + the contract = maximally consistent,
|
|
1356
1459
|
// no agent needed); agent-backed tasks (reasoning work) that spawn a PTY session are the documented
|
|
1357
1460
|
// extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
|
|
1358
|
-
|
|
1461
|
+
// Resolved per call (schedStore/skillsStore, above): normally `STATE/…`, and under any unsettled
|
|
1462
|
+
// state root the legacy copy where the per-user root holds none — read AND written, since it is
|
|
1463
|
+
// then the only copy on the machine. stateHealthNow() says so on every surface that lists them.
|
|
1464
|
+
// A function rather than a constant so that resolving the conflict, or fixing the permission that
|
|
1465
|
+
// refused the move, takes effect on the next read instead of on the next restart.
|
|
1466
|
+
function schedFile() { return schedStore().path; }
|
|
1359
1467
|
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
1360
1468
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
1361
1469
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
1362
1470
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
1363
1471
|
// derives it instantly, or a claude PTY session reasons through it) is decided by the file, not the PM.
|
|
1364
1472
|
const BUILTIN_SKILLS_DIR = path.join(__dirname, 'skills');
|
|
1365
|
-
|
|
1473
|
+
function userSkillsDir() { return skillsStore().path; }
|
|
1474
|
+
// The two paths a scheduled agent run must file into, spelled ABSOLUTELY. They used to be written
|
|
1475
|
+
// into the scaffold as `workspace/state/…`, which stopped being true the moment runtime state moved
|
|
1476
|
+
// out of the application directory (lib/state-root.js) — and a scaffold naming a directory the
|
|
1477
|
+
// console never reads is a report that silently never arrives. Derived from the same constants the
|
|
1478
|
+
// reader uses, so the two can only agree.
|
|
1479
|
+
//
|
|
1480
|
+
// `legacy` names where the runtime state USED to live — this application directory's own `state/`.
|
|
1481
|
+
// The retarget needs it because the PM's nine idpf-* skills spell the old location ABSOLUTELY
|
|
1482
|
+
// (`/Users/…/pmos/workspace/state/deliverables/<run-id>.md`), so there is no leading quote or space
|
|
1483
|
+
// to anchor a match on; the only question that can be answered reliably is "is this path's parent
|
|
1484
|
+
// the old state root", and only the caller knows where that was. Derived from __dirname, never
|
|
1485
|
+
// configured, so it cannot drift from the directory the migration moved out of.
|
|
1486
|
+
function filingContract(w) {
|
|
1487
|
+
return { deliverables: DELIV_DIR, needs: needsFileFor(w) || path.join(STATE, 'needs.json'),
|
|
1488
|
+
legacy: LEGACY_STATE };
|
|
1489
|
+
}
|
|
1366
1490
|
// Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
|
|
1367
1491
|
// load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
|
|
1368
1492
|
function loadTasks() {
|
|
1369
1493
|
return skillStore.loadSkills([
|
|
1370
|
-
{ dir:
|
|
1494
|
+
{ dir: userSkillsDir(), source: 'user' },
|
|
1371
1495
|
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
1372
1496
|
]).map(toTask);
|
|
1373
1497
|
}
|
|
@@ -1382,10 +1506,10 @@ function toTask(s) {
|
|
|
1382
1506
|
}
|
|
1383
1507
|
function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
|
|
1384
1508
|
function readSchedules() {
|
|
1385
|
-
try { const a = JSON.parse(fs.readFileSync(
|
|
1509
|
+
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
1386
1510
|
}
|
|
1387
1511
|
function writeSchedules(list) {
|
|
1388
|
-
try { fs.writeFileSync(
|
|
1512
|
+
try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
|
|
1389
1513
|
catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
|
|
1390
1514
|
}
|
|
1391
1515
|
|
|
@@ -1407,11 +1531,20 @@ function fireSchedule(sched) {
|
|
|
1407
1531
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
1408
1532
|
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
1409
1533
|
if (task && task.kind === 'agent') {
|
|
1534
|
+
// THE FILING CONTRACT IS RETARGETED HERE, on the prompt, at the moment it is handed to the
|
|
1535
|
+
// session (lib/skill-store.js#retargetFiling). The PM's eleven existing skills were authored
|
|
1536
|
+
// when runtime state lived at `workspace/state/`, so their bodies still say "write the report
|
|
1537
|
+
// to workspace/state/deliverables/". After the relocation that is a directory the console
|
|
1538
|
+
// never reads: the agent would succeed, report success, and the report would never arrive.
|
|
1539
|
+
// Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
|
|
1540
|
+
// eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
|
|
1541
|
+
// of the files either — the right answer for a given fire depends on which repo it fires in.
|
|
1542
|
+
const prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
|
|
1410
1543
|
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
1411
1544
|
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
1412
1545
|
// below rather than the control simply being hidden.
|
|
1413
1546
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
1414
|
-
origin: 'schedule:' + sched.id, initialInput:
|
|
1547
|
+
origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
|
|
1415
1548
|
profile: sched.profile == null ? null : sched.profile });
|
|
1416
1549
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
1417
1550
|
const started = s.state !== 'exited';
|
|
@@ -2247,6 +2380,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2247
2380
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
2248
2381
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
2249
2382
|
}
|
|
2383
|
+
announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
|
|
2250
2384
|
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
2251
2385
|
attachPty(s);
|
|
2252
2386
|
feed(s);
|
|
@@ -2266,6 +2400,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2266
2400
|
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
2267
2401
|
lastOutputAt: Date.now() };
|
|
2268
2402
|
sessions.set(id, s);
|
|
2403
|
+
announceStateHealth(s);
|
|
2269
2404
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
2270
2405
|
p.onData((d) => {
|
|
2271
2406
|
appendBuf(s, d);
|
|
@@ -2607,7 +2742,11 @@ function stateDirFor(w, { create = false } = {}) {
|
|
|
2607
2742
|
const d = path.join(w.root, ...rel);
|
|
2608
2743
|
if (isDir(d)) return d;
|
|
2609
2744
|
}
|
|
2610
|
-
|
|
2745
|
+
// pmos-self with no pmos/state/ yet: the checkout's OWN state/ is the file-mode mirror. This is
|
|
2746
|
+
// deliberately LEGACY_STATE and not STATE — D64's system of record belongs to the repository that
|
|
2747
|
+
// earned it, so it must not follow the runtime state out to the per-user directory. (Never a
|
|
2748
|
+
// tenant home — that writes to its OWN repo below.)
|
|
2749
|
+
if (EMBEDDED_SELF && w.root === SELF_ROOT) return LEGACY_STATE;
|
|
2611
2750
|
// A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
|
|
2612
2751
|
// invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
|
|
2613
2752
|
if (create && wsShape(w.root) === 'pmos') {
|
|
@@ -3408,7 +3547,9 @@ async function handleRequest(req, res) {
|
|
|
3408
3547
|
return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
|
|
3409
3548
|
note: 'a declaration, not a guarantee — nothing about either process changed' });
|
|
3410
3549
|
}
|
|
3411
|
-
|
|
3550
|
+
// `state_health` rides on the payload EVERY client reads at boot, so the unmigrated notice reaches
|
|
3551
|
+
// the PM whichever lens they open first — not only the two lists it is about.
|
|
3552
|
+
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList(), state_health: stateHealthNow() });
|
|
3412
3553
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
3413
3554
|
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
3414
3555
|
// route here — the server binds 127.0.0.1 and this only records a path.
|
|
@@ -3684,16 +3825,21 @@ async function handleRequest(req, res) {
|
|
|
3684
3825
|
if (p === '/api/skills' && req.method === 'GET') {
|
|
3685
3826
|
const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
|
|
3686
3827
|
description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
|
|
3687
|
-
|
|
3828
|
+
// NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
|
|
3829
|
+
// PM authored or is being read from the old directory, and in both cases the payload has to say
|
|
3830
|
+
// so — a calm list is a claim that the state is settled (see stateHealthNow).
|
|
3831
|
+
return json(res, { skills, state_health: stateHealthNow() });
|
|
3688
3832
|
}
|
|
3689
3833
|
// POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
|
|
3690
3834
|
// A GUI replaces editing server code; user skills default to running as a claude PTY session.
|
|
3691
3835
|
if (p === '/api/skills' && req.method === 'POST') {
|
|
3692
3836
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
3693
|
-
|
|
3837
|
+
// `filing` is built HERE, never taken from the request body: it names filesystem paths, and a
|
|
3838
|
+
// caller-supplied one would be a write instruction dressed up as a preference.
|
|
3839
|
+
const built = skillStore.scaffoldSkill({ ...body, filing: filingContract(getWs(body.ws)) });
|
|
3694
3840
|
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
3695
3841
|
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
3696
|
-
try { fs.mkdirSync(
|
|
3842
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
|
|
3697
3843
|
catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
3698
3844
|
return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
3699
3845
|
description: built.description, output: built.output, source: 'user' } });
|
|
@@ -3703,13 +3849,15 @@ async function handleRequest(req, res) {
|
|
|
3703
3849
|
// Always leaves a valid file even where claude is absent; the session is honest about not starting.
|
|
3704
3850
|
if (p === '/api/skills/draft' && req.method === 'POST') {
|
|
3705
3851
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
3706
|
-
const
|
|
3852
|
+
const w0 = getWs(body.ws);
|
|
3853
|
+
const filing = filingContract(w0);
|
|
3854
|
+
const built = skillStore.scaffoldSkill({ ...body, filing });
|
|
3707
3855
|
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
3708
3856
|
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
3709
|
-
try { fs.mkdirSync(
|
|
3857
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
|
|
3710
3858
|
catch { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
3711
|
-
const w =
|
|
3712
|
-
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(
|
|
3859
|
+
const w = w0;
|
|
3860
|
+
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(userSkillsDir(), built.slug + '.skill'));
|
|
3713
3861
|
const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
|
|
3714
3862
|
`"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
|
|
3715
3863
|
`follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
|
|
@@ -3717,7 +3865,7 @@ async function handleRequest(req, res) {
|
|
|
3717
3865
|
`ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
|
|
3718
3866
|
`own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
|
|
3719
3867
|
`condition. Keep the FILING CONTRACT the scaffold wrote — the run must write its deliverable to ` +
|
|
3720
|
-
|
|
3868
|
+
`${filing.deliverables}/<run-id>.md and append the structured object to ${filing.needs} ` +
|
|
3721
3869
|
`(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
|
|
3722
3870
|
`predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
|
|
3723
3871
|
`in the file itself. Then stop.`;
|
|
@@ -3787,7 +3935,9 @@ async function handleRequest(req, res) {
|
|
|
3787
3935
|
profile: s.profile == null ? null : s.profile,
|
|
3788
3936
|
profile_applies: !!(t && t.kind === 'agent') };
|
|
3789
3937
|
});
|
|
3790
|
-
|
|
3938
|
+
// Same rule as /api/skills: an empty (or fallen-back) schedule list under a conflict must never
|
|
3939
|
+
// read as "you have no scheduled tasks". A schedule that never loads never fires.
|
|
3940
|
+
return json(res, { schedules: list, now: nowISO(), state_health: stateHealthNow() });
|
|
3791
3941
|
}
|
|
3792
3942
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
3793
3943
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
@@ -3988,7 +4138,7 @@ async function handleRequest(req, res) {
|
|
|
3988
4138
|
// Stripped by spreading rather than deleting — some surfaces return frozen objects.
|
|
3989
4139
|
const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
|
|
3990
4140
|
if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
|
|
3991
|
-
return json(res, { name, ...out }, code);
|
|
4141
|
+
return json(res, { name, ...out, state_health: stateHealthNow() }, code);
|
|
3992
4142
|
}
|
|
3993
4143
|
// ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
|
|
3994
4144
|
// Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
|
|
@@ -4192,12 +4342,16 @@ async function handleRequest(req, res) {
|
|
|
4192
4342
|
// response so the client can be honest about where the decision landed.
|
|
4193
4343
|
let sink = acceptancesFileFor(w, { create: true });
|
|
4194
4344
|
const fallback = !sink;
|
|
4195
|
-
|
|
4345
|
+
// LEGACY_STATE, not STATE: an acceptance is a D64 record, and the last-resort sink for a repo
|
|
4346
|
+
// whose state directory could not be resolved stays with the install rather than following the
|
|
4347
|
+
// runtime state out to the per-user directory, where nobody would think to look for a record.
|
|
4348
|
+
if (fallback) sink = path.join(LEGACY_STATE, 'acceptances.jsonl');
|
|
4196
4349
|
// FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
|
|
4197
4350
|
// A swallowed append + {ok:true} would light the gate green over a record that never landed —
|
|
4198
4351
|
// exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
|
|
4199
4352
|
// {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
|
|
4200
4353
|
try {
|
|
4354
|
+
if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
|
|
4201
4355
|
fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
|
|
4202
4356
|
} catch (e) {
|
|
4203
4357
|
warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* test-run.js — THE ISOLATION A SUITE GETS BY CALLING ONE FUNCTION.
|
|
3
|
+
*
|
|
4
|
+
* Every suite in this workspace boots real servers, opens real PTYs and writes real state. The
|
|
5
|
+
* PM's live runtime state used to live at `workspace/state/`, INSIDE the directory the suites run
|
|
6
|
+
* from, so it was reachable BY CONSTRUCTION — by any verb, by any subprocess, by any stray shell
|
|
7
|
+
* command in the tree. `test.js#resetState()` removed sessions/, scratchpad/, workspaces.json,
|
|
8
|
+
* schedules.json, deliverables/ and skills/ from it five times a run, and the suite was safe in CI
|
|
9
|
+
* only because a fresh checkout has nothing there.
|
|
10
|
+
*
|
|
11
|
+
* The state has moved out (lib/state-root.js). What remains is to make sure a suite never resolves
|
|
12
|
+
* to the NEW per-user location either — an installed copy's state IS the PM's own, so "the app's
|
|
13
|
+
* own directory" was never the isolation boundary anybody thought it was. `configure()` points
|
|
14
|
+
* PMOS_STATE_DIR at a fresh scratch directory before `server.js` is ever required and before any
|
|
15
|
+
* child is spawned, so a run writes only into a directory it made.
|
|
16
|
+
*
|
|
17
|
+
* It also hands out a PRIVATE TMUX SOCKET (never the PM's own, `pmos`) and an EPHEMERAL PORT
|
|
18
|
+
* claimed from the kernel rather than a bet on 7599/7601-7606. The port is about collisions, not
|
|
19
|
+
* about state, and it is worth being exact about what it buys: it makes a collision RARE. It does
|
|
20
|
+
* not DETECT one — a suite that does collide still drives whatever answers, and proving whose
|
|
21
|
+
* server is on a port is a different defect with its own repair. Nothing here claims otherwise.
|
|
22
|
+
*
|
|
23
|
+
* It lives at the workspace root rather than under lib/ on purpose: lib/ is the SHIPPED server
|
|
24
|
+
* process, and no-network.test.js holds every file there to a standard this scaffolding does not
|
|
25
|
+
* need to meet. It belongs beside the suites.
|
|
26
|
+
*/
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { execFileSync } = require('child_process');
|
|
31
|
+
|
|
32
|
+
const HOST = '127.0.0.1';
|
|
33
|
+
|
|
34
|
+
let state = null; // set by configure(); this module is a no-op until then
|
|
35
|
+
|
|
36
|
+
/** Bind :0, read the port back, release it. Synchronous because callers need PORT as a const. */
|
|
37
|
+
function claimEphemeralPort() {
|
|
38
|
+
const src = "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()});";
|
|
39
|
+
const out = execFileSync(process.execPath, ['-e', src], { encoding: 'utf8' });
|
|
40
|
+
const n = Number(String(out).trim());
|
|
41
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error('could not claim an ephemeral port');
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Arm a suite. MUST be called before `require('./server')` and before any child is spawned —
|
|
47
|
+
* server.js resolves its state root at require time, and a child inherits the environment as it
|
|
48
|
+
* stood when it was spawned.
|
|
49
|
+
*
|
|
50
|
+
* Returns, and also exposes on the module: PORT, HOST, BASE, STATE_DIR.
|
|
51
|
+
*/
|
|
52
|
+
function configure({ name = 'run', port = null } = {}) {
|
|
53
|
+
if (state) return state;
|
|
54
|
+
const stateDir = process.env.PMOS_STATE_DIR
|
|
55
|
+
? path.resolve(process.env.PMOS_STATE_DIR)
|
|
56
|
+
: fs.mkdtempSync(path.join(os.tmpdir(), `pmos-state-${name}-`));
|
|
57
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
58
|
+
// In the environment BEFORE server.js is required here and before any child inherits it. Every
|
|
59
|
+
// boot site in every suite goes through process.env, so this is a chokepoint by construction
|
|
60
|
+
// rather than a convention each call site has to remember — and a convention that must be
|
|
61
|
+
// remembered at hundreds of call sites is a convention that will be forgotten at one of them,
|
|
62
|
+
// which is exactly how this class of defect got in.
|
|
63
|
+
process.env.PMOS_STATE_DIR = stateDir;
|
|
64
|
+
// A private tmux socket, never the PM's own ('pmos'), so a suite cannot reach their sessions.
|
|
65
|
+
process.env.PMOS_TMUX_SOCK = process.env.PMOS_TMUX_SOCK || `pmos-${name}-${process.pid}`;
|
|
66
|
+
|
|
67
|
+
const chosen = Number(port) > 0 ? Number(port) : claimEphemeralPort();
|
|
68
|
+
|
|
69
|
+
state = {
|
|
70
|
+
PORT: chosen, HOST, BASE: `http://${HOST}:${chosen}`, STATE_DIR: stateDir,
|
|
71
|
+
/** Environment for a spawned server/launcher: this run's state dir and tmux socket. */
|
|
72
|
+
env(extra = {}) {
|
|
73
|
+
return { ...process.env, PMOS_STATE_DIR: stateDir, ...extra };
|
|
74
|
+
},
|
|
75
|
+
/** A port nobody else holds, for a suite that needs a second server. */
|
|
76
|
+
port: claimEphemeralPort,
|
|
77
|
+
/** Remove the scratch state directory. Safe by construction: this run made it. */
|
|
78
|
+
cleanup() { try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} },
|
|
79
|
+
};
|
|
80
|
+
Object.assign(module.exports, state);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { configure, claimEphemeralPort, HOST };
|