create-pmos 0.55.2 → 0.55.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.55.2",
3
+ "version": "0.55.3",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@b7dc0a6
1
+ kernel-source: pmos@d183aab
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -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-06T07:31:23Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T08:46:09Z"}
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-06T07:31:23Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T08:46:09Z"}
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-06T07:31:23Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T08:46:09Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -18,6 +18,10 @@ Press **Prepare my day** to start the day. It runs from the brief to the deliver
18
18
 
19
19
  If the morning-brief session stops before it submits a brief, the Read step says so and gives the reason. **Prepare my day** is then ready again, so press it to try once more. If the session says that its folder is not there, open the repository again from its current folder first.
20
20
 
21
+ Each agent job runs in its own session, so you can open any job and follow or steer it. The workspace types the job's prompt into the session when the agent is ready to read, and presses Enter for you. When you approve or revise a job and its session is still open, the workspace uses that same session again.
22
+
23
+ **Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
24
+
21
25
  Each job has one of three kinds.
22
26
 
23
27
  | Kind | Who does it | What you do |
@@ -149,6 +149,12 @@ function deliver(run, taskId, d, now) {
149
149
  });
150
150
  }
151
151
 
152
+ /** The task leaves its session: `prev_session` keeps its id, so the next step can run in the same session while it is open. */
153
+ function keepSession(t) {
154
+ if (t.session_id) t.prev_session = t.session_id;
155
+ t.session_id = null;
156
+ }
157
+
152
158
  /**
153
159
  * The PM's decision on a draft.
154
160
  * approve review (approve task) → sending: a session will do exactly the approved step. done (auto task) → recorded.
@@ -162,7 +168,7 @@ function decide(run, taskId, op, now) {
162
168
  return withTask(run, taskId, (t) => {
163
169
  const at = iso(now);
164
170
  if (o.action === 'approve') {
165
- if (t.status === 'review' && t.kind === 'approve') { t.status = 'sending'; t.session_id = null; t.started_at = null; }
171
+ if (t.status === 'review' && t.kind === 'approve') { t.status = 'sending'; keepSession(t); t.started_at = null; }
166
172
  else if (!(t.status === 'done' && t.kind === 'auto')) return `task ${taskId} is ${t.status} — only a draft waiting for you can be approved`;
167
173
  t.decided = { action: 'approve', at, ...(note ? { note: note.slice(0, 500) } : {}) };
168
174
  return null;
@@ -173,11 +179,11 @@ function decide(run, taskId, op, now) {
173
179
  if (note.length > 500) return 'the note is over 500 characters';
174
180
  if (t.revisions.length >= MAX_REVISIONS) return `task ${taskId} has had ${MAX_REVISIONS} revisions — at most ${MAX_REVISIONS}. Do the rest yourself.`;
175
181
  t.revisions.push({ note, at });
176
- t.status = 'waiting'; t.session_id = null; t.decided = { action: 'revise', at, note };
182
+ t.status = 'waiting'; keepSession(t); t.decided = { action: 'revise', at, note };
177
183
  return null;
178
184
  }
179
185
  if (t.status !== 'failed') return `task ${taskId} is ${t.status} — only a failed task can be run again`;
180
- t.status = 'waiting'; t.session_id = null; t.error = null; t.decided = { action: 'retry', at };
186
+ t.status = 'waiting'; keepSession(t); t.error = null; t.decided = { action: 'retry', at };
181
187
  return null;
182
188
  });
183
189
  }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ /* first-input.js — how the workspace types a session's first input, the prompt it was started for.
3
+ *
4
+ * The old way wrote the whole prompt and its Enter as one burst, 700 ms after the start. An agent CLI such as Claude Code
5
+ * is not ready to read at 700 ms: it is still starting, it may ask its own startup questions, and it reads a fast burst of
6
+ * text as a paste. So the prompt was lost or sat in the input box without Enter, and the PM opened an empty session.
7
+ *
8
+ * Now the workspace waits until the agent is ready, then types the prompt, then presses Enter as a separate key:
9
+ * 1. READY: the pane printed something and then was quiet for QUIET_MS, and at least MIN_MS passed. A program that prints
10
+ * nothing (a plain reader of standard input) is ready after SILENT_MS. After MAX_MS it is typed in any case.
11
+ * 2. TYPE: when the program turned on bracketed paste (it printed ESC[?2004h), the prompt is sent as one paste, so its
12
+ * lines stay one prompt. Otherwise it is sent as typed text.
13
+ * 3. ENTER: SUBMIT_MS later, Enter alone. A bracketed-paste program that printed nothing after that Enter gets Enter once
14
+ * more after RETRY_MS, because the first one can land while the paste is still being read.
15
+ * The clock and the timers are injected, so the plan is tested without waiting. No I/O of its own: it writes only through
16
+ * `write`.
17
+ */
18
+
19
+ const MIN_MS = 1200;
20
+ const QUIET_MS = 800;
21
+ const SILENT_MS = 3000;
22
+ const MAX_MS = 20000;
23
+ const POLL_MS = 200;
24
+ const SUBMIT_MS = 500;
25
+ const RETRY_MS = 1500;
26
+ const PASTE_ON = '\x1b[?2004h';
27
+ const PASTE_OFF = '\x1b[?2004l';
28
+
29
+ /** True when the last bracketed-paste switch in `buf` turned it on. */
30
+ function pasteOn(buf) {
31
+ const b = String(buf || '');
32
+ return b.lastIndexOf(PASTE_ON) > b.lastIndexOf(PASTE_OFF);
33
+ }
34
+
35
+ /** Is the pane ready for typed input? `t0` is when the wait began; `firstOut` is when output first came after t0. */
36
+ function ready({ now, t0, lastOutputAt, firstOut }) {
37
+ const waited = now - t0;
38
+ if (waited >= MAX_MS) return true;
39
+ if (waited < MIN_MS) return false;
40
+ if (!firstOut) return waited >= SILENT_MS;
41
+ return now - lastOutputAt >= QUIET_MS;
42
+ }
43
+
44
+ /** Split a first input into the text and whether it ended with Enter. */
45
+ function splitInput(input) {
46
+ const s = String(input == null ? '' : input);
47
+ const m = /[\r\n]+$/.exec(s);
48
+ return { text: m ? s.slice(0, m.index) : s, enter: !!m };
49
+ }
50
+
51
+ /**
52
+ * Type `input` into session `s` once it is ready. `s` is read live (s.pty, s.buf, s.lastOutputAt, s.state).
53
+ * @param {object} s the session record
54
+ * @param {string} input the prompt, with a trailing Enter when it must be submitted
55
+ * @param {object} [o] { now, setTimeout, onDone } — injected for tests
56
+ */
57
+ function deliver(s, input, o = {}) {
58
+ const now = o.now || (() => Date.now());
59
+ const later = o.setTimeout || setTimeout;
60
+ const done = typeof o.onDone === 'function' ? o.onDone : () => {};
61
+ const { text, enter } = splitInput(input);
62
+ if (!s || (!text && !enter)) { done('nothing'); return; }
63
+ const t0 = now();
64
+ const base = s.lastOutputAt || 0;
65
+ let firstOut = 0;
66
+ const alive = () => s.pty && s.state !== 'exited';
67
+ const write = (d) => { try { s.pty.write(d); return true; } catch { return false; } };
68
+ const tick = () => {
69
+ if (!alive()) { done('gone'); return; }
70
+ if (!firstOut && (s.lastOutputAt || 0) > base) firstOut = s.lastOutputAt;
71
+ if (!ready({ now: now(), t0, lastOutputAt: s.lastOutputAt || 0, firstOut })) { later(tick, POLL_MS); return; }
72
+ const paste = pasteOn(s.buf);
73
+ if (text) write(paste ? '\x1b[200~' + text + '\x1b[201~' : text);
74
+ if (!enter) { done('typed'); return; }
75
+ later(() => {
76
+ if (!alive()) { done('gone'); return; }
77
+ const before = s.lastOutputAt || 0;
78
+ write('\r');
79
+ if (!paste) { done('submitted'); return; }
80
+ later(() => {
81
+ if (alive() && (s.lastOutputAt || 0) <= before) { write('\r'); done('resubmitted'); return; }
82
+ done('submitted');
83
+ }, RETRY_MS);
84
+ }, text ? SUBMIT_MS : 0);
85
+ };
86
+ later(tick, POLL_MS);
87
+ }
88
+
89
+ module.exports = { deliver, ready, pasteOn, splitInput, MIN_MS, QUIET_MS, SILENT_MS, MAX_MS, SUBMIT_MS, RETRY_MS };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.2",
3
+ "version": "0.55.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.2",
3
+ "version": "0.55.3",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -122,8 +122,12 @@ function build(ctx0) {
122
122
  // joined to the cards of the first repository in scope, and its initiatives open in that repository
123
123
  const dayRepo = sc.repos.length ? sc.repos[0] : null;
124
124
  const dayWs = dayRepo ? wsOf(dayRepo) : null;
125
- const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null });
126
- return { children: [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
125
+ // the open day takes the list into its left column ("Also waiting for you"), so Today shows one layout; a folded day
126
+ // leaves it below, as before
127
+ const owed = { list, more, count: n };
128
+ const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null, owed });
129
+ const inDay = !!(day && day.contains(list));
130
+ return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
127
131
  }
128
132
 
129
133
  /** The key of the control that has the focus inside the board, so it can be focused again after a rebuild. */
@@ -128,7 +128,11 @@ export const COPY = {
128
128
  unreached: 'The workspace did not answer.',
129
129
  refused: (why) => `Nothing changed. ${why}`,
130
130
  oldServer: 'This workspace server has no day run yet. Only your goals show.',
131
- panels: { calendar: 'Calendar', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', detail: 'Detail' },
131
+ panels: { summary: 'Agent results', calendar: 'Calendar', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', waiting: 'Also waiting for you', detail: 'Detail' },
132
+ summary: (c) => [c.review ? `${c.review} ${c.review === 1 ? 'draft waits' : 'drafts wait'} for you` : null, c.done ? `${c.done} done` : null,
133
+ c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
134
+ summaryAll: (n) => `All ${n} agent ${n === 1 ? 'job has' : 'jobs have'} a result.`,
135
+ waitingHint: 'Outcome checks, reports and sessions from your repositories, outside today\'s jobs.',
132
136
  now: (at) => `now ${at}`,
133
137
  noCalendar: 'No events read for today.',
134
138
  noJobs: 'No jobs yet. Press Prepare my day. Each job shows here; select one to see what it needs.',
@@ -378,6 +378,38 @@ function jobsPanel(ctx) {
378
378
  el('ul', { class: 'td-day__rows' }, ...l.map((j) => jobRow(ctx, j))))));
379
379
  }
380
380
 
381
+ /* The coordinator's view of the run: what every agent job came back with, in one place. The server runs the jobs as one
382
+ * session each and keeps their state; this reads that state, so it needs no session of its own. */
383
+ function summaryPanel(ctx) {
384
+ const { m } = ctx;
385
+ const ts = m.tasks;
386
+ if (!ts.length) return null;
387
+ const c = { review: 0, done: 0, working: 0, failed: 0 };
388
+ for (const t of ts) {
389
+ if (t.status === 'review') c.review++;
390
+ else if (t.status === 'done' || t.status === 'sent') c.done++;
391
+ else if (t.status === 'failed') c.failed++;
392
+ else c.working++;
393
+ }
394
+ const finished = ts.filter((t) => t.status !== 'waiting' && t.status !== 'working' && t.status !== 'sending');
395
+ const byTask = new Map(m.jobs.filter((j) => j.task).map((j) => [j.task.id, j]));
396
+ const rows = finished.map((t) => {
397
+ const j = byTask.get(t.id);
398
+ const what = t.status === 'failed' ? t.error : t.sent && t.sent.link ? t.sent.link : t.draft ? t.draft.summary : '';
399
+ return j ? rowBtn(ctx, 'job', j.id, { lead: '·', title: j.title, meta: what || '', trail: statusPill(j) }) : null;
400
+ }).filter(Boolean);
401
+ return panel('summary', null,
402
+ el('p', { class: 'td-day__note', 'data-day': 'summary' }, c.working ? D.summary(c) : D.summaryAll(ts.length) + ' ' + D.summary(c)),
403
+ rows.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'summary' }, ...rows) : null);
404
+ }
405
+
406
+ /* What waits for the PM outside today's jobs (the Today cards: outcome checks, reports, sessions). Today hands its list
407
+ * in, so the cards keep their own controls and the open day shows one layout. */
408
+ function waitingPanel(owed) {
409
+ if (!owed || !owed.list) return null;
410
+ return panel('waiting', owed.count ? String(owed.count) : null, note(D.waitingHint), owed.list, owed.more || null);
411
+ }
412
+
381
413
  function goalsPanel(ctx) {
382
414
  const { m } = ctx;
383
415
  return panel('goals', D.goalsCount(m.goals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), goalAdd(ctx));
@@ -721,7 +753,7 @@ function fold(open) {
721
753
  }
722
754
 
723
755
  /** The section. `ws` is the repository the day run belongs to; `openIni(id)` opens an initiative of it. */
724
- export function daySection({ ws, setState, openIni } = {}) {
756
+ export function daySection({ ws, setState, openIni, owed } = {}) {
725
757
  if (FIXTURE) return null;
726
758
  // like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
727
759
  // cause a whole-page render under whatever the PM is doing elsewhere
@@ -755,7 +787,7 @@ export function daySection({ ws, setState, openIni } = {}) {
755
787
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
756
788
  m.d && m.d.legacy ? note(D.oldServer) : null,
757
789
  el('div', { class: 'td-day__cols' },
758
- el('div', { class: 'td-day__left' }, calendar(ctx), jobsPanel(ctx), goalsPanel(ctx), mentionsPanel(ctx)),
790
+ el('div', { class: 'td-day__left' }, summaryPanel(ctx), calendar(ctx), jobsPanel(ctx), goalsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
759
791
  detail(ctx)));
760
792
  }
761
793
 
@@ -85,6 +85,7 @@ const repoWatch = require('./lib/repo-watch.js');
85
85
  tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
86
86
  const scheduleLogic = require('./lib/schedule-logic');
87
87
  const { safeFirstInput } = require('./lib/prompt-input');
88
+ const firstInput = require('./lib/first-input');
88
89
  const transcriptsLib = require('./lib/transcripts');
89
90
  const initiativeFiles = require('./lib/initiative-files');
90
91
  const { deriveNextSteps } = require('./lib/next-steps');
@@ -3135,7 +3136,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3135
3136
  // a scheduled run tags the session with its origin, and feeds the skill invocation as first input
3136
3137
  const tag = origin || null;
3137
3138
  // safeFirstInput: a first input that begins with `/` is read by the agent as a slash command, not as the task.
3138
- const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(safeFirstInput(initialInput)); } catch {} }, 700); } catch {} } };
3139
+ // firstInput.deliver waits until the agent is ready to read, types the prompt, then presses Enter as its own key
3140
+ // (lib/first-input.js): a burst written at a fixed 700 ms was lost or left unsent while the agent was still starting.
3141
+ const feed = (s) => { if (initialInput && s && s.pty) { try { firstInput.deliver(s, safeFirstInput(initialInput)); } catch {} } };
3139
3142
  // shell gets a real fallback to `sh`; agent CLIs run as-is (harness untouched), NO fallback —
3140
3143
  // falling back from `claude` to `sh` would be a lie.
3141
3144
  const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
@@ -3647,8 +3650,15 @@ function daySkillPrompt(run, skillId) {
3647
3650
  function startDayTask(run, t, now) {
3648
3651
  const sk = daySkillPrompt(run, 'day-task');
3649
3652
  let s = null;
3650
- if (!sk.error) {
3651
- const prompt = `${sk.prompt}\n\nThis run: /day-task ${t.id} ${t.mode} — read the task first with: pmos proactive day show ${t.id}`;
3653
+ const line = `This run: /day-task ${t.id} ${t.mode} — read the task first with: pmos proactive day show ${t.id}`;
3654
+ // The session that did the task's last step is used again while it is open: it has the skill and the task in its
3655
+ // context already, so only this run's line is typed into it, and the PM keeps one session per job.
3656
+ const prev = t.prev_session && sessions.get(t.prev_session);
3657
+ if (!sk.error && prev && prev.state !== 'exited' && prev.pty) {
3658
+ try { firstInput.deliver(prev, line + '\r'); s = prev; } catch { s = null; }
3659
+ }
3660
+ if (!sk.error && !s) {
3661
+ const prompt = `${sk.prompt}\n\n${line}`;
3652
3662
  s = spawnSession({ name: `${t.title} · day task`, agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
3653
3663
  modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
3654
3664
  }