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.
@@ -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,
@@ -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
- const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
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
- if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
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
- return json(res, { ws: w ? w.id : null, ...out, lanes });
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