create-pmos 0.71.0 → 0.72.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.
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+ /* routes-my-work.js — the My work destination's routes (initiative my-work, PRD R1 to R5, R11).
3
+ *
4
+ * GET /api/my-work → { ok, today, read_at, items, counts, banner } every item assigned to the PM,
5
+ * ranked (lib/my-work.js fold), across every registered repository
6
+ * POST /api/my-work/read { items } → { ok, read_at, count } the my-work-read skill's submission (`pmos my-work submit`)
7
+ * POST /api/my-work/act { action: brief|handle|reopen, item, instruction?, ws }
8
+ * → { ok, item, task? } the PM's press; a brief adds one day task (R5)
9
+ * POST /api/my-work/refresh { ws } → { ok, session } Read again: starts the my-work-read skill
10
+ *
11
+ * STATE. The latest read is <runtime>/my-work/read.json and the presses are <runtime>/my-work/actions.jsonl, append-only.
12
+ * Both stay in the per-user runtime state and never land in a repository: they hold paraphrases of Jira comments. The
13
+ * WBS items are folded from each repository's Board (lib/board-fold.js, filter mine) on every GET, and nothing is
14
+ * written to a plan ledger (D88).
15
+ *
16
+ * THE SERVER NEVER READS JIRA (D53, D71). An agent session reads it with the PM's own connection and posts the read.
17
+ * The two presses that start a session (act brief, refresh) pass pm-guard requirePagePress first: an agent session
18
+ * cannot brief itself or another agent.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const guard = require('./pm-guard.js');
23
+ const myWork = require('./my-work.js');
24
+ const boardFold = require('./board-fold.js');
25
+ const { localDate } = require('./brief.js');
26
+
27
+ const dirOf = (ctx) => path.join(ctx.runtimeDir, 'my-work');
28
+ const readFile = (ctx) => path.join(dirOf(ctx), 'read.json');
29
+ const actionsFile = (ctx) => path.join(dirOf(ctx), 'actions.jsonl');
30
+
31
+ function loadRead(ctx) {
32
+ try { const r = JSON.parse(fs.readFileSync(readFile(ctx), 'utf8')); return r && Array.isArray(r.items) ? r : null; } catch { return null; }
33
+ }
34
+ function loadActions(ctx) {
35
+ try { return myWork.parseActions(fs.readFileSync(actionsFile(ctx), 'utf8')); } catch { return []; }
36
+ }
37
+
38
+ /** The whole list, as GET answers it. */
39
+ function listFor(ctx, now) {
40
+ const today = localDate(now);
41
+ const banner = [];
42
+ const boards = [];
43
+ const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
44
+ for (const w of all) {
45
+ try {
46
+ const b = boardFold.boardFor({ id: w.id, root: w.root }, { filter: 'mine', now });
47
+ if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
48
+ else banner.push({ source: 'wbs:' + w.id, error: b.error });
49
+ } catch (e) { banner.push({ source: 'wbs:' + w.id, error: String((e && e.message) || e).slice(0, 160) }); }
50
+ }
51
+ const read = loadRead(ctx);
52
+ const actions = loadActions(ctx);
53
+ const runs = {};
54
+ for (const d of new Set(actions.filter((a) => a.action === 'brief').map((a) => String(a.at).slice(0, 10)).concat([today]))) {
55
+ try { const r = ctx.readDayRun ? ctx.readDayRun(d) : null; if (r) runs[d] = r; } catch { /* a run that cannot be read is no run */ }
56
+ }
57
+ // a run of a later day can hold a task too: today's covers every brief made today
58
+ const out = myWork.fold({ read, wbs: myWork.wbsItems(boards), actions, runs, today });
59
+ return { ok: true, today, read_at: read ? read.read_at || null : null, items: out.items, counts: out.counts, banner };
60
+ }
61
+
62
+ function writeAtomic(file, text) {
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ const tmp = `${file}.${process.pid}.tmp`;
65
+ fs.writeFileSync(tmp, text);
66
+ fs.renameSync(tmp, file);
67
+ }
68
+
69
+ async function handle(req, res, url, ctx) {
70
+ const p = url.pathname;
71
+ const now = Date.now();
72
+ if (p === '/api/my-work') {
73
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
74
+ ctx.json(res, listFor(ctx, now));
75
+ return true;
76
+ }
77
+ if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh'].includes(p)) return false;
78
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
79
+ const got = await ctx.readJson(req);
80
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
81
+ const body = got.body;
82
+
83
+ if (p === '/api/my-work/read') {
84
+ const c = myWork.checkRead(body, now);
85
+ if (!c.ok) { ctx.json(res, { ok: false, error: c.error }, 400); return true; }
86
+ try { writeAtomic(readFile(ctx), JSON.stringify(c.read) + '\n'); }
87
+ catch { ctx.json(res, { ok: false, error: 'could not save the read — nothing changed' }, 500); return true; }
88
+ ctx.json(res, { ok: true, read_at: c.read.read_at, count: c.read.items.length });
89
+ return true;
90
+ }
91
+
92
+ const g = guard.requirePagePress(req, body);
93
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
94
+
95
+ if (p === '/api/my-work/refresh') {
96
+ const r = ctx.myWorkRead(String(body.ws || ''));
97
+ ctx.json(res, r.ok ? { ok: true, session: r.session } : { ok: false, error: r.error }, r.ok ? 200 : r.status || 500);
98
+ return true;
99
+ }
100
+
101
+ // act
102
+ const c = myWork.checkAct(body, now);
103
+ if (!c.ok) { ctx.json(res, { ok: false, error: c.error }, 400); return true; }
104
+ const list = listFor(ctx, now);
105
+ const item = list.items.find((x) => x.id === c.record.item);
106
+ if (!item) { ctx.json(res, { ok: false, error: `no item ${c.record.item} is assigned to you` }, 404); return true; }
107
+ const why = myWork.refusal(item, c.record.action);
108
+ if (why) { ctx.json(res, { ok: false, error: why }, 409); return true; }
109
+ let task = null;
110
+ if (c.record.action === 'brief') {
111
+ const b = ctx.myWorkBrief(myWork.taskSpec(item, c.record), String(body.ws || ''));
112
+ if (!b.ok) { ctx.json(res, { ok: false, error: b.error }, b.status || 500); return true; }
113
+ task = b.task;
114
+ }
115
+ try { fs.mkdirSync(dirOf(ctx), { recursive: true }); fs.appendFileSync(actionsFile(ctx), JSON.stringify(c.record) + '\n'); }
116
+ catch { ctx.json(res, { ok: false, error: 'could not save your press' }, 500); return true; }
117
+ const after = listFor(ctx, Date.now()).items.find((x) => x.id === item.id) || null;
118
+ ctx.json(res, { ok: true, item: after, task });
119
+ return true;
120
+ }
121
+
122
+ module.exports = { prefixes: ['/api/my-work'], handle, listFor };
@@ -118,7 +118,36 @@ async function status(root) {
118
118
  }
119
119
 
120
120
  // The press. Returns { ok, steps:[{step, ok, note}], stopped_at?, message }.
121
- async function sync(root, { label } = {}) {
121
+ // While a replay stops on a conflict, the PM's choice settles each conflicted file whole. In a rebase, stage 2 (`--ours`)
122
+ // is the remote's side being built on, and stage 3 (`--theirs`) is this machine's commit being replayed. A side that
123
+ // deleted the file is honoured by removing it. A commit left with nothing of its own is skipped. Bounded: each round
124
+ // replays one commit, so the loop ends after at most one round per unpushed commit plus one.
125
+ async function settleReplay(root, prefer, rounds) {
126
+ for (let i = 0; i <= rounds; i++) {
127
+ const rp = await run(root, ['rev-parse', '--git-path', 'rebase-merge']);
128
+ if (!rp.ok || !fs.existsSync(path.resolve(root, rp.out))) return { ok: true };
129
+ const u = await run(root, ['diff', '--name-only', '--diff-filter=U']);
130
+ const files = u.ok && u.out ? u.out.split('\n').filter(Boolean) : [];
131
+ if (!files.length) return { ok: false, err: 'the replay stopped with no conflicted file' };
132
+ const stage = prefer === 'mine' ? '3' : '2';
133
+ for (const f of files) {
134
+ const has = await run(root, ['cat-file', '-e', `:${stage}:${f}`]);
135
+ const r = has.ok ? await run(root, ['checkout', prefer === 'mine' ? '--theirs' : '--ours', '--', f]) : await run(root, ['rm', '-q', '--', f]);
136
+ if (!r.ok) return { ok: false, err: r.err };
137
+ if (has.ok) { const a = await run(root, ['add', '--', f]); if (!a.ok) return { ok: false, err: a.err }; }
138
+ }
139
+ const empty = (await run(root, ['diff', '--cached', '--quiet'])).ok;
140
+ const next = await run(root, ['-c', 'core.editor=true', 'rebase', empty ? '--skip' : '--continue']);
141
+ if (next.ok) return { ok: true };
142
+ }
143
+ return { ok: false, err: 'the replay did not finish' };
144
+ }
145
+
146
+ // `prefer` is the PM's answer after a stop on a conflict, and only the press passes it (never auto-sync): 'mine' keeps
147
+ // this machine's version of each file both machines changed, 'theirs' keeps the other machine's. Every other change
148
+ // from both machines is kept either way. Still a rebase of this machine's own commits: no force, no merge commit.
149
+ async function sync(root, { label, prefer } = {}) {
150
+ if (prefer !== undefined && prefer !== 'mine' && prefer !== 'theirs') prefer = undefined;
122
151
  const steps = [];
123
152
  const stop = (step, message) => { steps.push({ step, ok: false, note: message }); return { ok: false, steps, stopped_at: step, message }; };
124
153
  const done = (step, note) => steps.push({ step, ok: true, note });
@@ -157,20 +186,26 @@ async function sync(root, { label } = {}) {
157
186
  if (s2.behind) {
158
187
  // Fast-forward when we have nothing of our own; otherwise replay OUR unpushed commits on top.
159
188
  const attrs = s2.ahead ? unionAttributesFor(root) : null;
160
- const verb = s2.ahead ? [...(attrs ? ['-c', `core.attributesFile=${attrs}`] : []), 'rebase', '--autostash', s2.upstream]
189
+ // With the PM's choice, a conflicted hunk takes that side (-X); in a rebase `theirs` is this machine's commit.
190
+ const side = prefer && s2.ahead ? ['-X', prefer === 'mine' ? 'theirs' : 'ours'] : [];
191
+ const verb = s2.ahead ? [...(attrs ? ['-c', `core.attributesFile=${attrs}`] : []), 'rebase', '--autostash', ...side, s2.upstream]
161
192
  : ['merge', '--ff-only', s2.upstream];
162
- const r = await run(root, verb);
193
+ let r = await run(root, verb);
194
+ if (!r.ok && prefer && s2.ahead) r = await settleReplay(root, prefer, s2.ahead);
163
195
  if (!r.ok) {
164
196
  if (!s2.ahead) return stop('integrate', `cannot fast-forward — ${r.err.split('\n')[0] || 'see git'}`);
165
197
  // Name the files before the abort clears them: the PM has to open these, and "the same lines" alone says not where.
166
198
  const u = await run(root, ['diff', '--name-only', '--diff-filter=U']);
167
199
  const files = u.ok && u.out ? u.out.split('\n').filter(Boolean) : [];
168
200
  await run(root, ['rebase', '--abort']);
169
- return stop('integrate', files.length
170
- ? `both machines changed ${files.join(', ')} — rebase aborted, nothing changed; keep the version you want in each file on one machine, sync it, then sync the other`
171
- : `the two machines changed the same lines — rebase aborted, nothing changed — ${r.err.split('\n')[0] || 'see git'}`);
201
+ const out = stop('integrate', files.length
202
+ ? `both machines changed ${files.join(', ')} — rebase aborted, nothing changed; choose which machine's version to keep`
203
+ : `the two machines changed the same lines — rebase aborted, nothing changed — ${(r.err || '').split('\n')[0] || 'see git'}`);
204
+ // The surface offers the choice only for a conflict it can settle, and never again once one was made.
205
+ if (!prefer) out.conflict = { files };
206
+ return out;
172
207
  }
173
- done('integrate', s2.ahead ? `replayed ${s2.ahead} local commit(s) on the remote` : `fast-forwarded ${s2.behind} commit(s)`);
208
+ done('integrate', s2.ahead ? `replayed ${s2.ahead} local commit(s) on the remote${prefer ? `, keeping ${prefer === 'mine' ? 'this' : 'the other'} machine's version where both changed a file` : ''}` : `fast-forwarded ${s2.behind} commit(s)`);
174
209
  } else done('integrate', 'nothing to bring in');
175
210
 
176
211
  const s3 = await status(root);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
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.71.0",
3
+ "version": "0.72.0",
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",
@@ -39,7 +39,7 @@ import { isMobile, mountMobile, applyMobile } from './mobile.js';
39
39
  /* The frame's parts (Run 3, slice 3A). Small modules with no effect at import: a fixture page loads them and calls none. */
40
40
  import { createTopbar, unsyncedOf } from './topbar.js';
41
41
  import { createDockRail } from './dock.js';
42
- import { SEL, ensureComponentsCss, iconButton, menu, toast } from './primitives.js';
42
+ import { SEL, ensureComponentsCss, iconButton, menu, toast, sheet, button } from './primitives.js';
43
43
  import { ensureSprite } from './icons.js';
44
44
  import { COPY as FC } from './frame-copy.js';
45
45
  import { registerCommand } from './registry.js';
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'mywork', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -512,17 +512,34 @@ function syncReading(repo) {
512
512
  }
513
513
  return syncSeen.get(repo).st;
514
514
  }
515
- async function runSync() {
515
+ async function runSync(prefer) {
516
516
  const repo = fr.scope === 'all' ? null : activeRepo();
517
517
  if (!repo || fr.syncing || !mods.data || typeof mods.data.syncRepository !== 'function') return;
518
518
  fr.syncing = true;
519
519
  setState({});
520
520
  let res;
521
- try { res = await mods.data.syncRepository(repo); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
521
+ const opts = prefer === 'mine' || prefer === 'theirs' ? { prefer } : undefined;
522
+ try { res = await mods.data.syncRepository(repo, opts); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
522
523
  fr.syncing = false;
523
524
  syncSeen.delete(repo);
524
- toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && (res.stopped_at || res.step)) || FC.sync.label, (res && (res.message || res.error)) || '') });
525
525
  setState({});
526
+ // Both machines changed the same lines and nothing changed: the PM chooses which version to keep (D70 amended
527
+ // 2026-10-07). The server offers the choice only once per press, so a choice that still stops ends in the toast.
528
+ if (res && !res.ok && res.conflict) return chooseSyncSide(res.conflict.files || []);
529
+ toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && (res.stopped_at || res.step)) || FC.sync.label, (res && (res.message || res.error)) || '') });
530
+ }
531
+ function chooseSyncSide(files) {
532
+ const C = FC.sync.conflict;
533
+ let s = null;
534
+ const pick = (side) => () => { s.close(); runSync(side); };
535
+ s = sheet({
536
+ title: C.title, sub: C.sub(files), body: [el('p', {}, C.body)], attrs: { 'data-sync-conflict': '' },
537
+ actions: [
538
+ button({ label: C.push, variant: 'primary', onClick: pick('mine'), attrs: { 'data-sync-prefer': 'mine' } }),
539
+ button({ label: C.pull, onClick: pick('theirs'), attrs: { 'data-sync-prefer': 'theirs' } }),
540
+ button({ label: C.cancel, variant: 'ghost', onClick: () => s.close() }),
541
+ ],
542
+ });
526
543
  }
527
544
 
528
545
  /* Closing the tab with work that is not yet pushed asks first (D70 as amended 2026-10-06): idle auto-sync waits for a
@@ -637,7 +654,7 @@ function pressIn(r, key) {
637
654
  if (b && !b.disabled) { b.click(); return true; }
638
655
  return false;
639
656
  }
640
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people' };
657
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people', w: 'mywork' };
641
658
 
642
659
  function frameKey(e) {
643
660
  const mod = e.metaKey || e.ctrlKey;
@@ -5653,6 +5653,123 @@
5653
5653
  }
5654
5654
  }
5655
5655
 
5656
+ /* My work (my-work): the work assigned to the PM, ranked, beside the chosen item's context card and brief. */
5657
+ .rp-mywork {
5658
+ display: flex;
5659
+ flex-direction: column;
5660
+ gap: 16px;
5661
+ }
5662
+ .rp-mywork-top {
5663
+ display: flex;
5664
+ flex-wrap: wrap;
5665
+ align-items: center;
5666
+ gap: 12px;
5667
+ }
5668
+ .rp-mywork-top > .rp-head__title {
5669
+ margin-right: auto;
5670
+ }
5671
+ .rp-mywork-body {
5672
+ display: flex;
5673
+ flex-direction: column;
5674
+ gap: 16px;
5675
+ }
5676
+ .rp-mywork-grid {
5677
+ display: grid;
5678
+ grid-template-columns: minmax(280px, 3fr) 2fr;
5679
+ gap: 24px;
5680
+ align-items: start;
5681
+ }
5682
+ .rp-mywork-list {
5683
+ display: flex;
5684
+ flex-direction: column;
5685
+ gap: 8px;
5686
+ min-width: 0;
5687
+ }
5688
+ .rp-mywork-views {
5689
+ border-bottom: 1px solid var(--border);
5690
+ }
5691
+ .rp-mywork-row {
5692
+ border-top: 1px solid var(--border);
5693
+ }
5694
+ .rp-mywork-row:last-child {
5695
+ border-bottom: 1px solid var(--border);
5696
+ }
5697
+ .rp-mywork-row--open {
5698
+ box-shadow: inset 2px 0 0 var(--foreground);
5699
+ }
5700
+ .rp-mywork-state {
5701
+ display: inline-flex;
5702
+ align-items: center;
5703
+ color: var(--muted-foreground);
5704
+ }
5705
+ .rp-mywork-page {
5706
+ display: flex;
5707
+ flex-direction: column;
5708
+ gap: 16px;
5709
+ min-width: 0;
5710
+ }
5711
+ .rp-mywork-who {
5712
+ display: flex;
5713
+ flex-direction: column;
5714
+ gap: 4px;
5715
+ }
5716
+ .rp-mywork-who__title {
5717
+ margin: 0;
5718
+ font-size: 16px;
5719
+ font-weight: 500;
5720
+ }
5721
+ .rp-mywork-link {
5722
+ display: inline-flex;
5723
+ align-items: center;
5724
+ gap: 4px;
5725
+ font-size: 12px;
5726
+ color: var(--foreground);
5727
+ }
5728
+ .rp-mywork-sec,
5729
+ .rp-mywork-brief,
5730
+ .rp-mywork-status {
5731
+ display: flex;
5732
+ flex-direction: column;
5733
+ gap: 8px;
5734
+ }
5735
+ .rp-mywork-sec__title,
5736
+ .rp-mywork-sub {
5737
+ margin: 0;
5738
+ font-size: 12px;
5739
+ font-weight: 500;
5740
+ color: var(--muted-foreground);
5741
+ }
5742
+ .rp-mywork-hint {
5743
+ margin: 0;
5744
+ font-size: 12px;
5745
+ color: var(--muted-foreground);
5746
+ }
5747
+ .rp-mywork-text {
5748
+ margin: 0;
5749
+ font-size: 14px;
5750
+ overflow-wrap: anywhere;
5751
+ }
5752
+ .rp-mywork-quote {
5753
+ padding-left: 8px;
5754
+ border-left: 2px solid var(--border);
5755
+ white-space: pre-wrap;
5756
+ }
5757
+ .rp-mywork-open {
5758
+ margin: 0;
5759
+ padding-left: 16px;
5760
+ font-size: 14px;
5761
+ }
5762
+ .rp-mywork-acts {
5763
+ display: flex;
5764
+ flex-wrap: wrap;
5765
+ gap: 8px;
5766
+ }
5767
+ @media (max-width: 760px) {
5768
+ .rp-mywork-grid {
5769
+ grid-template-columns: 1fr;
5770
+ }
5771
+ }
5772
+
5656
5773
  /* -- layouts · .nt- Notes: one meeting on one screen — notes and what was caught beside a quiet transcript (meeting-notes Runs 1 and 2) -- */
5657
5774
  .nt-wrap {
5658
5775
  display: flex;
@@ -1559,11 +1559,12 @@ export async function syncStatusOf(name) {
1559
1559
  return getJson('/api/sync/status', { ws });
1560
1560
  }
1561
1561
 
1562
- export async function syncRepository(name) {
1562
+ // prefer: 'mine' | 'theirs' — the PM's answer after Sync stopped on a conflict (D70 as amended 2026-10-07).
1563
+ export async function syncRepository(name, { prefer } = {}) {
1563
1564
  if (FIXTURE) return { ok: false, message: 'fixture mode — nothing to sync' };
1564
1565
  const ws = wsOf(name);
1565
1566
  if (!ws) return { ok: false, message: 'unknown repository' };
1566
- const res = await postJson('/api/sync', { ws });
1567
+ const res = await postJson('/api/sync', prefer ? { ws, prefer } : { ws });
1567
1568
  const p = products.get(name);
1568
1569
  if (res && res.ok && p) { p.board = null; await loadBoard(p); publishBoards(); }
1569
1570
  return res || { ok: false, message: 'the workspace server did not answer' };