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 +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/guide/layout.md +1 -1
- package/workspace/lib/sync.js +42 -7
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +21 -4
- package/workspace/public/pm/data.js +3 -2
- package/workspace/public/pm/frame-copy.js +9 -0
- package/workspace/server.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.71.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
|
package/workspace/lib/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
? `both machines changed ${files.join(', ')} — rebase aborted, nothing changed;
|
|
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);
|
package/workspace/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: {
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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); }
|