create-pmos 0.71.0 → 0.71.1

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.71.0",
3
+ "version": "0.71.1",
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@c87956b
1
+ kernel-source: pmos@5a5029e
2
2
  assembled: 2026-10-07
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-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:40:31Z"}
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-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:40:31Z"}
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-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:40:31Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -17,7 +17,7 @@ The top bar has these parts, from left to right:
17
17
  - **Branches and sessions** is in ⌘K. It opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
18
18
  - **Agenda, Initiatives, Board, Reports, Docs and People** are the six places. Press one to show it. The Agenda count shows how many items need you. Docs shows whether each document is the same as its Google Docs and Confluence copy. People shows the people you work with in the repository ([People](people.md)).
19
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
20
- - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, the message names the file. Keep the version you want in that file on one machine, sync it, then sync the other machine.
20
+ - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, Sync changes nothing and names the files. Then choose **Push this machine’s version** or **Pull the other machine’s version**. Sync keeps that version of those files, keeps all other changes from both machines, and finishes. It never overwrites the remote history.
21
21
  - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
22
22
  - **Settings** opens the settings of the workspace.
23
23
 
@@ -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.71.1",
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.71.1",
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';
@@ -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
@@ -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' };
@@ -41,6 +41,15 @@ export const COPY = {
41
41
  title: 'Sync this repository: fetch, bring in the work of the other machine, record your changes, push.',
42
42
  done: 'The repository is in sync.',
43
43
  stopped: (step, why) => 'Sync stopped at ' + step + ': ' + why,
44
+ /** Sync stopped because both machines changed the same lines. The PM chooses which version to keep. */
45
+ conflict: {
46
+ title: 'Both machines changed the same files',
47
+ sub: (files) => (files.length ? files.join(', ') : 'Git did not name the files.'),
48
+ body: 'Nothing changed. Choose which version to keep in these files. All other changes from both machines are kept.',
49
+ push: 'Push this machine’s version',
50
+ pull: 'Pull the other machine’s version',
51
+ cancel: 'Not now',
52
+ },
44
53
  },
45
54
  settings: 'Settings',
46
55
  dock: {
@@ -7095,7 +7095,9 @@ async function handleRequest(req, res) {
7095
7095
  if (syncing.has(w.root)) return json(res, { ok: false, error: 'a sync is already running for this repository' }, 409);
7096
7096
  syncing.add(w.root);
7097
7097
  try {
7098
- const out = await syncLib.sync(w.root, { label: os.hostname() });
7098
+ // After a stop on a conflict the PM may answer which machine's version to keep; only this press passes it.
7099
+ const prefer = body.prefer === 'mine' || body.prefer === 'theirs' ? body.prefer : undefined;
7100
+ const out = await syncLib.sync(w.root, { label: os.hostname(), prefer });
7099
7101
  boardCache.invalidate(w.root);
7100
7102
  return json(res, { ws: w.id, name: w.name, ...out });
7101
7103
  } finally { syncing.delete(w.root); }