create-pmos 0.38.7 → 0.38.8
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/README.md +35 -0
- package/workspace/bin/pmos.js +10 -1
- package/workspace/lib/initiative-files.js +111 -0
- package/workspace/lib/next-steps.js +113 -0
- package/workspace/lib/prompt-input.js +18 -0
- package/workspace/lib/schedule-cli.js +256 -0
- package/workspace/lib/schedule-logic.js +23 -1
- package/workspace/lib/skill-store.js +11 -3
- package/workspace/lib/status-groups.js +66 -0
- package/workspace/lib/transcripts.js +73 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +2 -0
- package/workspace/public/pm/data.js +23 -2
- package/workspace/public/pm/modes-live.js +19 -0
- package/workspace/public/pm/panel-detail.js +84 -0
- package/workspace/public/pm/panel-initiatives.js +37 -1
- package/workspace/public/pm/panel-tabs.js +38 -2
- package/workspace/public/pm/panel-trees.js +9 -2
- package/workspace/public/pm/state.js +7 -2
- package/workspace/server.js +112 -5
|
@@ -24,7 +24,7 @@ const STORE_KEY = 'pmos.workspace.pm.v1';
|
|
|
24
24
|
/** The initial state — the prototype's `state = {…}` block, key for key. */
|
|
25
25
|
function initial() {
|
|
26
26
|
return {
|
|
27
|
-
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, settingsOpen: false,
|
|
27
|
+
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, settingsOpen: false,
|
|
28
28
|
product: null, intgSynced: {},
|
|
29
29
|
addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
|
|
30
30
|
addLog: [], addedProducts: [], extraKnow: {},
|
|
@@ -153,7 +153,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
|
|
|
153
153
|
// a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
|
|
154
154
|
'url',
|
|
155
155
|
// a skill tab's skill id — a reload reopens the same skill
|
|
156
|
-
'skillId'
|
|
156
|
+
'skillId',
|
|
157
|
+
// a conversation tab's session id — a reload reopens the same transcript
|
|
158
|
+
'convId'];
|
|
157
159
|
const MAX_TABS = 40;
|
|
158
160
|
|
|
159
161
|
function packTab(t) {
|
|
@@ -181,6 +183,9 @@ const PERSIST = {
|
|
|
181
183
|
product: isSlug,
|
|
182
184
|
sel: isSlug,
|
|
183
185
|
collapsedGroups: isFlagMap,
|
|
186
|
+
// how the initiative list is grouped: by objective (default) or by derived status (initiative-surface-batch R2)
|
|
187
|
+
groupBy: (v) => v === 'objective' || v === 'status',
|
|
188
|
+
activeOnly: isBool,
|
|
184
189
|
collapsedPhases: isFlagMap,
|
|
185
190
|
// per doc tab: true = 72ch measure, false = full width; absent = the viewport default (R5.1)
|
|
186
191
|
docNarrow: isFlagMap,
|
package/workspace/server.js
CHANGED
|
@@ -63,6 +63,10 @@ ensureSpawnHelperExecutable();
|
|
|
63
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
64
64
|
const { createSinkGuard } = require('./lib/sink.js');
|
|
65
65
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
66
|
+
const { safeFirstInput } = require('./lib/prompt-input');
|
|
67
|
+
const transcriptsLib = require('./lib/transcripts');
|
|
68
|
+
const initiativeFiles = require('./lib/initiative-files');
|
|
69
|
+
const { deriveNextSteps } = require('./lib/next-steps');
|
|
66
70
|
const harness = require('./lib/harness-parse');
|
|
67
71
|
const transcript = require('./lib/transcript-read');
|
|
68
72
|
const skillStore = require('./lib/skill-store');
|
|
@@ -2040,6 +2044,13 @@ const SEED_SETS = {
|
|
|
2040
2044
|
function readSchedules() {
|
|
2041
2045
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
2042
2046
|
}
|
|
2047
|
+
// R5 — count a fire against the schedule's own limits. A schedule that has run `max_runs` times, or whose `until`
|
|
2048
|
+
// day is over, turns itself off and says why; nothing about a schedule without either changes.
|
|
2049
|
+
function countRun(cur, now = new Date()) {
|
|
2050
|
+
cur.runs = (+cur.runs || 0) + 1;
|
|
2051
|
+
const why = scheduleLogic.finishReason(cur, now);
|
|
2052
|
+
if (why) { cur.enabled = false; cur.endedReason = why; cur.nextRun = null; }
|
|
2053
|
+
}
|
|
2043
2054
|
function writeSchedules(list) {
|
|
2044
2055
|
try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
|
|
2045
2056
|
catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
|
|
@@ -2088,7 +2099,7 @@ function fireSchedule(sched) {
|
|
|
2088
2099
|
modelAsk: { scheduleTier: sched.tier || null, skillTier: task.tier || null, skill: task.skill } });
|
|
2089
2100
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
2090
2101
|
const started = s.state !== 'exited';
|
|
2091
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; writeSchedules(list); }
|
|
2102
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); if (cur.cadence === 'once') cur.enabled = false; countRun(cur); writeSchedules(list); }
|
|
2092
2103
|
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
2093
2104
|
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
2094
2105
|
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
@@ -2110,7 +2121,7 @@ function fireSchedule(sched) {
|
|
|
2110
2121
|
// stamp lastRun/lastStatus/nextRun via the pure module
|
|
2111
2122
|
const list = readSchedules();
|
|
2112
2123
|
const cur = list.find((s) => s.id === sched.id);
|
|
2113
|
-
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
2124
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); countRun(cur); writeSchedules(list); }
|
|
2114
2125
|
// surface it — the finished run reaches the PM through needs-you (R5), honest on failure
|
|
2115
2126
|
try {
|
|
2116
2127
|
const nf = needsFileFor(w);
|
|
@@ -2131,6 +2142,7 @@ function checkDueSchedules() {
|
|
|
2131
2142
|
try {
|
|
2132
2143
|
const list = readSchedules(); let changed = false;
|
|
2133
2144
|
for (const s of list) if (scheduleLogic.isMissed(s)) { s.enabled = false; s.lastStatus = 'missed'; s.nextRun = null; changed = true; }
|
|
2145
|
+
for (const s of list) { const why = s.enabled && scheduleLogic.finishReason(s); if (why) { s.enabled = false; s.endedReason = why; s.nextRun = null; changed = true; } }
|
|
2134
2146
|
if (changed) writeSchedules(list);
|
|
2135
2147
|
} catch {}
|
|
2136
2148
|
return fired;
|
|
@@ -2145,7 +2157,29 @@ const sessions = new Map();
|
|
|
2145
2157
|
const allClients = new Set();
|
|
2146
2158
|
let seq = 0;
|
|
2147
2159
|
|
|
2160
|
+
// The initiative a session belongs to, for the conversation record: the PM's or spawn's explicit word first,
|
|
2161
|
+
// else the `initiative/<id>` branch its working tree is on (the same join the initiative detail makes).
|
|
2162
|
+
function initiativeOfSession(s) {
|
|
2163
|
+
if (s && s.initiative) return String(s.initiative);
|
|
2164
|
+
try {
|
|
2165
|
+
const w = wsList().find((x) => x.id === (s && s.ws));
|
|
2166
|
+
const m = w && w.branch ? /^initiative\/(.+)$/.exec(String(w.branch)) : null;
|
|
2167
|
+
return m ? m[1] : null;
|
|
2168
|
+
} catch { return null; }
|
|
2169
|
+
}
|
|
2170
|
+
// Save the conversation of a session that belongs to an initiative, so ending or removing it no longer erases it.
|
|
2171
|
+
// Runtime state only, never the repository (PM ruling 2026-10-03). Quiet when the session is on no initiative.
|
|
2172
|
+
function recordConversation(s) {
|
|
2173
|
+
const initiative = initiativeOfSession(s);
|
|
2174
|
+
if (!initiative) return { ok: false, skipped: true };
|
|
2175
|
+
const r = transcriptsLib.save(STATE, { id: s.id, name: s.name, agent: s.agent, ws: s.ws, initiative,
|
|
2176
|
+
state: s.state, started: s.created ? new Date(s.created).toISOString() : null, text: s.buf });
|
|
2177
|
+
if (!r.ok) warnWriteFail('transcripts (conversation record)', new Error(r.error));
|
|
2178
|
+
return r;
|
|
2179
|
+
}
|
|
2148
2180
|
function persist(s) {
|
|
2181
|
+
if (s.state === 'exited' && !s._txSaved) { s._txSaved = true; try { recordConversation(s); } catch {} }
|
|
2182
|
+
else if (s.state !== 'exited') s._txSaved = false;
|
|
2149
2183
|
// note IS persisted — a restored session must still say why it exited. `root` is persisted
|
|
2150
2184
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
2151
2185
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
@@ -2872,7 +2906,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2872
2906
|
try { memoryLib.renderFor(root); } catch { /* memory is never a reason a session fails to start */ }
|
|
2873
2907
|
// a scheduled run tags the session with its origin, and feeds the skill invocation as first input
|
|
2874
2908
|
const tag = origin || null;
|
|
2875
|
-
|
|
2909
|
+
// safeFirstInput: a first input that begins with `/` is read by the agent as a slash command, not as the task.
|
|
2910
|
+
const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(safeFirstInput(initialInput)); } catch {} }, 700); } catch {} } };
|
|
2876
2911
|
// shell gets a real fallback to `sh`; agent CLIs run as-is (harness untouched), NO fallback —
|
|
2877
2912
|
// falling back from `claude` to `sh` would be a lie.
|
|
2878
2913
|
const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
|
|
@@ -3302,6 +3337,7 @@ function removeSession(id) {
|
|
|
3302
3337
|
const s = sessions.get(id);
|
|
3303
3338
|
if (!s || s.pty) return false;
|
|
3304
3339
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
3340
|
+
try { recordConversation(s); } catch {} // the conversation outlives the session (surface-batch R5)
|
|
3305
3341
|
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
3306
3342
|
// Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
|
|
3307
3343
|
// believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
|
|
@@ -4229,6 +4265,7 @@ async function handleRequest(req, res) {
|
|
|
4229
4265
|
// is the same two-hopper lib/four-panel.js makes per initiative on the server; the board makes it
|
|
4230
4266
|
// once over every session, from this module, so 178 rows cost no requests.
|
|
4231
4267
|
if (p === '/lib/working-set.js') return serveFile(res, path.join(__dirname, 'lib', 'working-set.js'));
|
|
4268
|
+
if (p === '/lib/status-groups.js') return serveFile(res, path.join(__dirname, 'lib', 'status-groups.js'));
|
|
4232
4269
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
4233
4270
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
4234
4271
|
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
@@ -4840,6 +4877,29 @@ async function handleRequest(req, res) {
|
|
|
4840
4877
|
if (err) return json(res, { ok: false, error: err }, 500);
|
|
4841
4878
|
return json(res, { ok: true, prefs });
|
|
4842
4879
|
}
|
|
4880
|
+
// One recorded conversation (R5): the live session's text, or the saved record once it has ended. Read-only.
|
|
4881
|
+
if (p === '/api/transcript' && req.method === 'GET') {
|
|
4882
|
+
const id = String(url.searchParams.get('id') || '');
|
|
4883
|
+
const live = sessions.get(id);
|
|
4884
|
+
if (live) {
|
|
4885
|
+
const initiative = initiativeOfSession(live);
|
|
4886
|
+
return json(res, { ok: true, id, name: live.name, initiative, state: live.state, live: live.state !== 'exited',
|
|
4887
|
+
stored: 'the runtime state directory — not the repository, and never sent anywhere',
|
|
4888
|
+
text: transcriptsLib.stripAnsi(live.buf).slice(-transcriptsLib.MAX_CHARS) });
|
|
4889
|
+
}
|
|
4890
|
+
const rec = transcriptsLib.read(STATE, id);
|
|
4891
|
+
if (!rec) return json(res, { ok: false, error: 'no recorded conversation with that id' }, 404);
|
|
4892
|
+
return json(res, { ok: true, ...rec, live: false, stored: 'the runtime state directory — not the repository, and never sent anywhere' });
|
|
4893
|
+
}
|
|
4894
|
+
// The tail of one session's terminal text, escape codes stripped, so a CLI can turn "what I just did" into a skill.
|
|
4895
|
+
// Read-only, local, and capped; it names the session it read or says there is none.
|
|
4896
|
+
if (p === '/api/sessions/transcript' && req.method === 'GET') {
|
|
4897
|
+
const sess = sessions.get(String(url.searchParams.get('id') || ''));
|
|
4898
|
+
if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4899
|
+
const max = Math.min(60000, Math.max(500, +url.searchParams.get('max') || 20000));
|
|
4900
|
+
const text = transcriptsLib.stripAnsi(sess.buf).slice(-max);
|
|
4901
|
+
return json(res, { ok: true, id: sess.id, name: sess.name, chars: text.length, text });
|
|
4902
|
+
}
|
|
4843
4903
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
4844
4904
|
// `all=1` (overhaul-r4 D1): every repository's schedules — the drawer lists `scheduled` under "all
|
|
4845
4905
|
// repositories", so filtering to the home workspace there would hide the PM's other schedules
|
|
@@ -4905,8 +4965,32 @@ async function handleRequest(req, res) {
|
|
|
4905
4965
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
4906
4966
|
const list = readSchedules(); const s = list.find((x) => x.id === body.id);
|
|
4907
4967
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
4908
|
-
|
|
4968
|
+
// R5 — `until` and `max_runs` are validated by the same rules a new schedule is, and null/'' clears one. A refused value
|
|
4969
|
+
// returns BEFORE anything is changed, so a bad edit leaves the record as it was.
|
|
4970
|
+
const lim = {};
|
|
4971
|
+
for (const k of ['until', 'max_runs']) if (k in body) lim[k] = body[k];
|
|
4972
|
+
if (Object.keys(lim).length) {
|
|
4973
|
+
const chk = scheduleLogic.normalizeSchedule({ name: s.name, cadence: s.cadence === 'once' ? 'daily' : s.cadence, skill: s.skill, ...lim });
|
|
4974
|
+
if (!chk.ok) return json(res, { ok: false, error: chk.error }, 400);
|
|
4975
|
+
for (const k of Object.keys(lim)) { if (lim[k] == null || lim[k] === '') delete s[k]; else s[k] = chk.schedule[k]; }
|
|
4976
|
+
}
|
|
4977
|
+
if (typeof body.enabled === 'boolean') { s.enabled = body.enabled; if (body.enabled) delete s.endedReason; }
|
|
4978
|
+
// Every field an edit may set is checked BEFORE any is applied (R2): a refused edit names the allowed values and
|
|
4979
|
+
// leaves the record untouched. `skill` must be one that loads for this schedule's repository.
|
|
4980
|
+
const bad = (m) => json(res, { ok: false, error: m }, 400);
|
|
4981
|
+
if (body.cadence != null && !scheduleLogic.CADENCES.includes(body.cadence)) return bad(`cadence must be one of ${scheduleLogic.CADENCES.join(', ')}`);
|
|
4982
|
+
if (body.day != null && !['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].includes(body.day)) return bad('day must be one of Mon, Tue, Wed, Thu, Fri, Sat, Sun');
|
|
4983
|
+
if (body.dayOfMonth != null && !(Number.isInteger(+body.dayOfMonth) && +body.dayOfMonth >= 1 && +body.dayOfMonth <= 28)) return bad('dayOfMonth must be a whole number from 1 to 28');
|
|
4984
|
+
if (body.time != null && !/^([01]?\d|2[0-3]):[0-5]\d$/.test(String(body.time))) return bad('time must be HH:MM, 24-hour, this machine\'s clock');
|
|
4985
|
+
if (body.date != null && !/^\d{4}-\d{2}-\d{2}$/.test(String(body.date))) return bad('date must be YYYY-MM-DD');
|
|
4986
|
+
if (body.name != null && !String(body.name).trim()) return bad('name cannot be empty');
|
|
4987
|
+
if (body.args != null && String(body.args).length > 2000) return bad('args is limited to 2000 characters');
|
|
4988
|
+
for (const k of ['skill', 'task']) if (body[k] != null) {
|
|
4989
|
+
const ws0 = workspaces.get(s.ws);
|
|
4990
|
+
if (!taskById(body[k], ws0 ? ws0.root : null)) return bad(`no skill named "${body[k]}" is available to this schedule`);
|
|
4991
|
+
}
|
|
4909
4992
|
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill', 'date', 'args']) if (body[k] != null) s[k] = body[k];
|
|
4993
|
+
if ('args' in body && (body.args === null || body.args === '')) delete s.args;
|
|
4910
4994
|
if ('tier' in body) { if (body.tier === null || body.tier === '') delete s.tier; else if (/^(fast|balanced|deep)$/.test(String(body.tier))) s.tier = String(body.tier); else return json(res, { ok: false, error: 'tier must be fast, balanced or deep' }, 400); }
|
|
4911
4995
|
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
4912
4996
|
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
@@ -5189,7 +5273,30 @@ async function handleRequest(req, res) {
|
|
|
5189
5273
|
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
5190
5274
|
let lanes = null;
|
|
5191
5275
|
try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
|
|
5192
|
-
|
|
5276
|
+
// R4/R5/R6 — what remains to reach acceptance, the conversations that belong to this initiative, and the
|
|
5277
|
+
// files git says were changed for it. Each is read, never guessed; a failed read says so.
|
|
5278
|
+
const sessRows = sessionList();
|
|
5279
|
+
const liveConv = sessRows.filter((x) => initiativeOfSession(sessions.get(x.id)) === id)
|
|
5280
|
+
.map((x) => ({ id: x.id, name: x.name, state: x.state === 'exited' ? 'exited' : 'live', live: x.state !== 'exited', ws: x.ws }));
|
|
5281
|
+
const savedConv = transcriptsLib.listFor(STATE, id).filter((c) => !liveConv.some((l) => l.id === c.id))
|
|
5282
|
+
.map((c) => ({ id: c.id, name: c.name, state: 'ended', live: false, ws: c.ws, saved: c.saved, chars: c.chars, cut: c.cut }));
|
|
5283
|
+
const conversations = [...liveConv, ...savedConv];
|
|
5284
|
+
const listed = new Set(((out && out.artifacts) || []).map((a) => a && a.path).filter(Boolean));
|
|
5285
|
+
let files;
|
|
5286
|
+
try { files = initiativeFiles.filesFor(root, id, { exclude: listed }); }
|
|
5287
|
+
catch (e) { files = { ok: false, reason: 'the files could not be read — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
5288
|
+
let nextSteps = null;
|
|
5289
|
+
try {
|
|
5290
|
+
const acc = readAcceptances(w).get(id) || {};
|
|
5291
|
+
const lc = readLifecycle(w).get(id) || null;
|
|
5292
|
+
nextSteps = deriveNextSteps({
|
|
5293
|
+
id, accepted: acc.action === 'accept', returned: acc.action === 'return',
|
|
5294
|
+
lifecycle: lc && lc.state !== 'active' ? lc : null, ladder,
|
|
5295
|
+
needs: (readNeeds(w) || []).filter((n) => n && n.initiative_id === id),
|
|
5296
|
+
sessions: sessRows.filter((x) => (x.initiative === id) || conversations.some((c) => c.id === x.id && c.live)),
|
|
5297
|
+
});
|
|
5298
|
+
} catch (e) { nextSteps = { state: 'unreadable', summary: 'The next steps could not be derived — ' + String((e && e.message) || 'error').slice(0, 120), items: [] }; }
|
|
5299
|
+
return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps });
|
|
5193
5300
|
}
|
|
5194
5301
|
if (p === '/api/audit' && req.method === 'GET') {
|
|
5195
5302
|
/* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
|