create-pmos 0.126.0 → 0.128.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.
- 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/c3-reports-test.js +314 -0
- package/workspace/fixtures/one-home-baseline.json +29 -9
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/layout.md +2 -2
- package/workspace/guide/reports-dashboards.md +57 -0
- package/workspace/lib/git-info.js +19 -1
- package/workspace/lib/report-dashboards.js +194 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +108 -0
- package/workspace/lib/session-origin.js +35 -10
- package/workspace/lib/transcript-read.js +17 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-cal-strategy.js +2 -1
- package/workspace/public/pm/c3-demo-rep3.js +97 -0
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-reports.css +62 -0
- package/workspace/public/pm/c3-reports.js +454 -0
- package/workspace/public/pm/c3-shell.js +3 -1
- package/workspace/server.js +30 -3
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/console3-reports.json +6 -0
- package/workspace/test-lists/local-test-kit.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.128.0",
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06: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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06: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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T03:06:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* c3-reports-test.js — the console3 Reports dashboards in a browser, demo mode (initiative console3-reports, PRD R1 to R7).
|
|
4
|
+
*
|
|
5
|
+
* WHERE. The suite first loads /c3.html?demo=1&tab=rep. When the shell mounts the dashboards there ([data-rp3] in the Reports pane),
|
|
6
|
+
* it runs against the shell. Until c3-shell.js has the mount line, it serves a small harness page from this process (page.route, no
|
|
7
|
+
* file in public/) that installs demo mode and mounts mountReports with the same ctx the shell passes ({ ws, pmGet, drafts }, drafts
|
|
8
|
+
* doing what repTab does). The suite prints which one it used.
|
|
9
|
+
*
|
|
10
|
+
* tabs Dashboards, Library and Drafts; Dashboards first
|
|
11
|
+
* scopes Portfolio draws its seven default reports from the Calendar's builders; Board and Initiative show a chooser, and a
|
|
12
|
+
* report shows only the rows of the chosen board or initiative; a scope's add list holds only reports that fit it
|
|
13
|
+
* cache the data route is read once per scope (switching back reads nothing)
|
|
14
|
+
* edit up and down, Remove with Undo, Add from the report library, Reset to default; each change POSTs { key, reports }
|
|
15
|
+
* and a failed POST is toasted while the page keeps the change
|
|
16
|
+
* reload a stored layout is there after a reload
|
|
17
|
+
* share the frame (Headline, Sections, Needs your call, Sources) for Leadership and for Team; Save as draft POSTs ONE draft;
|
|
18
|
+
* the schedule control is disabled and says why
|
|
19
|
+
* library one row per report with data-home, never on the dashboard; a widget carries data-ref-row and opens its case in place
|
|
20
|
+
* drafts the Drafts tab shows the report families
|
|
21
|
+
* phone 400px: one column, no horizontal page scroll
|
|
22
|
+
* errors no script error and no console error
|
|
23
|
+
*/
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawn } = require('child_process');
|
|
26
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
27
|
+
const T = require('./test-run').configure({ name: 'c3-reports' });
|
|
28
|
+
|
|
29
|
+
const BASE = T.BASE;
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
const until = async (fn, ms = 10000, step = 100) => {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); }
|
|
34
|
+
};
|
|
35
|
+
const results = [];
|
|
36
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
|
|
37
|
+
let srv = null; let browser = null;
|
|
38
|
+
const finish = async (code) => {
|
|
39
|
+
if (browser) await browser.close().catch(() => {});
|
|
40
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
41
|
+
T.killTmux();
|
|
42
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
43
|
+
console.log(`\n${results.length - bad}/${results.length} c3-reports assertions passed`);
|
|
44
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
45
|
+
};
|
|
46
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
47
|
+
|
|
48
|
+
const HARNESS = '/__c3-reports-harness.html';
|
|
49
|
+
const HARNESS_HTML = `<!doctype html><html lang="en" data-pm-page-theme="light"><head><meta charset="utf-8" />
|
|
50
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="pmos-nonce" content="" />
|
|
51
|
+
<title>Reports harness</title><link rel="stylesheet" href="/pm/tokens.css" />
|
|
52
|
+
<style>html,body{margin:0;background:var(--background);color:var(--foreground)} #c3-root{padding:16px}</style></head>
|
|
53
|
+
<body><div id="c3-root" class="c3 c3pg" data-pm-theme="light"><div data-c3-pane="rep" id="host"></div></div>
|
|
54
|
+
<script type="module">
|
|
55
|
+
const demo = await import('/pm/c3-demo.js');
|
|
56
|
+
demo.installDemo(); if (window.__c3demoReady) await window.__c3demoReady;
|
|
57
|
+
const { el } = await import('/pm/dom.js');
|
|
58
|
+
const { ensureC3Css, c3Phone } = await import('/pm/pm-work-view.js');
|
|
59
|
+
const { pmGet } = await import('/pm/http.js');
|
|
60
|
+
const { mountReports } = await import('/pm/c3-reports.js');
|
|
61
|
+
const { mountReportFamilies } = await import('/pm/report-families-view.js');
|
|
62
|
+
const { mountOutbox } = await import('/pm/outbox-panel.js');
|
|
63
|
+
ensureC3Css(); c3Phone(document.getElementById('c3-root'));
|
|
64
|
+
const ws = demo.DEMO_WS;
|
|
65
|
+
const drafts = (pane) => {
|
|
66
|
+
const fam = el('div', { 'data-c3pg': 'families-host' }); const out = el('div', { class: 'brief', 'data-c3pg': 'outbox-host' });
|
|
67
|
+
pane.append(fam, out);
|
|
68
|
+
pmGet('/api/report-families/facts?ws=' + encodeURIComponent(ws)).then((j) => {
|
|
69
|
+
mountReportFamilies(fam, { reportFacts: j && j.ok ? j.facts : {}, plan: null, portfolio: null });
|
|
70
|
+
mountOutbox(out, {});
|
|
71
|
+
const sync = () => { out.hidden = !fam.querySelector('[data-family="communications"][aria-pressed="true"]'); };
|
|
72
|
+
sync(); new MutationObserver(sync).observe(fam, { subtree: true, attributes: true, attributeFilter: ['aria-pressed'] });
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
mountReports(document.getElementById('host'), { ws, pmGet, drafts });
|
|
76
|
+
document.getElementById('c3-root').setAttribute('data-c3-demo', '1');
|
|
77
|
+
</script></body></html>`;
|
|
78
|
+
|
|
79
|
+
const D = '[data-rp3-pane="dash"]';
|
|
80
|
+
const order = (page) => page.$$eval(D + ' [data-rp3-w]', (a) => a.map((n) => n.getAttribute('data-rp3-w')));
|
|
81
|
+
const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'maturity'];
|
|
82
|
+
|
|
83
|
+
(async () => {
|
|
84
|
+
const exe = findBrowser();
|
|
85
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-reports suite cannot run'); return finish(1); }
|
|
86
|
+
let chromium;
|
|
87
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-reports suite cannot run'); return finish(1); }
|
|
88
|
+
|
|
89
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
90
|
+
await T.waitForServer(BASE);
|
|
91
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
92
|
+
|
|
93
|
+
const errors = [];
|
|
94
|
+
const watch = (page) => {
|
|
95
|
+
page.on('pageerror', (e) => errors.push('pageerror: ' + String((e && e.message) || e)));
|
|
96
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
|
97
|
+
page.on('dialog', (d) => d.dismiss().catch(() => {}));
|
|
98
|
+
};
|
|
99
|
+
/* the shell when it mounts the dashboards, else the harness (see the header) */
|
|
100
|
+
let mode = null;
|
|
101
|
+
const open = async (page) => {
|
|
102
|
+
await page.route(BASE + HARNESS, (r) => r.fulfill({ status: 200, contentType: 'text/html', body: HARNESS_HTML }));
|
|
103
|
+
if (mode !== 'harness') {
|
|
104
|
+
await page.goto(BASE + '/c3.html?demo=1&tab=rep');
|
|
105
|
+
const inShell = await until(() => page.$('[data-c3-pane="rep"] [data-rp3]'), mode === 'shell' ? 15000 : 4000);
|
|
106
|
+
if (inShell) { mode = 'shell'; return true; }
|
|
107
|
+
}
|
|
108
|
+
mode = 'harness';
|
|
109
|
+
await page.goto(BASE + HARNESS);
|
|
110
|
+
return !!(await until(() => page.$('[data-c3-pane="rep"] [data-rp3]'), 15000));
|
|
111
|
+
};
|
|
112
|
+
const loaded = (page, n) => until(() => page.$$eval(D + ' [data-rp3-w]', (a, k) => a.length === k && a.every((w) => w.querySelector('section.card') && !w.querySelector('[data-rp3-loading]')), n));
|
|
113
|
+
/* counts this page's reads of the data route and its POSTs, from the outermost fetch wrapper (demo answers never reach the network) */
|
|
114
|
+
const spy = (page) => page.evaluate(() => {
|
|
115
|
+
if (window.__rp3spy) { window.__rp3spy.reads = []; window.__rp3spy.posts = []; return; }
|
|
116
|
+
const S = window.__rp3spy = { reads: [], posts: [], fail: null };
|
|
117
|
+
const f = window.fetch;
|
|
118
|
+
window.fetch = (input, init) => {
|
|
119
|
+
const u = new URL(typeof input === 'string' ? input : input.url, location.origin);
|
|
120
|
+
const m = String((init && init.method) || 'GET').toUpperCase();
|
|
121
|
+
if (m === 'GET' && u.pathname === '/api/report-dashboards/data') S.reads.push(u.searchParams.get('scope') + '|' + (u.searchParams.get('id') || ''));
|
|
122
|
+
if (m === 'POST') { let b = null; try { b = JSON.parse(init.body); } catch { b = null; } S.posts.push({ path: u.pathname, body: b }); if (S.fail && u.pathname === S.fail) return Promise.resolve(new Response(JSON.stringify({ ok: false, error: 'the store is read-only' }), { status: 500, headers: { 'content-type': 'application/json' } })); }
|
|
123
|
+
return f(input, init);
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
const posts = (page, p) => page.evaluate((x) => window.__rp3spy.posts.filter((q) => q.path === x), p);
|
|
127
|
+
|
|
128
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
129
|
+
const page = await ctx.newPage();
|
|
130
|
+
watch(page);
|
|
131
|
+
const up = await open(page);
|
|
132
|
+
ok('the Reports dashboards come up in demo mode (' + mode + ')', up);
|
|
133
|
+
if (!up) return finish(1);
|
|
134
|
+
|
|
135
|
+
/* ── tabs ── */
|
|
136
|
+
const tabs = await page.$$eval('[data-rp3] [role="tab"]', (a) => a.map((b) => [b.getAttribute('data-rp3-tab'), b.textContent.trim(), b.getAttribute('aria-selected')]));
|
|
137
|
+
ok('three tabs: Dashboards, Library, Drafts, with Dashboards selected', tabs.length === 3 && tabs[0][0] === 'dash' && /^Dashboards/.test(tabs[0][1]) && tabs[0][2] === 'true' && /^Library/.test(tabs[1][1]) && /^Drafts/.test(tabs[2][1]), JSON.stringify(tabs));
|
|
138
|
+
|
|
139
|
+
/* ── portfolio ── */
|
|
140
|
+
ok('Portfolio draws its seven default reports, each a builder card', !!(await loaded(page, 7)), JSON.stringify(await order(page)));
|
|
141
|
+
ok('the portfolio order is the default order', JSON.stringify(await order(page)) === JSON.stringify(DEF_P), JSON.stringify(await order(page)));
|
|
142
|
+
const kinds = await page.$$eval(D + ' [data-rp3-w]', (a) => a.map((w) => { const c = w.querySelector('section.card'); return w.getAttribute('data-rp3-w') + ':' + (c && c.getAttribute('data-cs')); }));
|
|
143
|
+
ok('each widget is the Calendar builder of its report (data-cs matches, delivery included)', kinds.every((k) => { const [a, b] = k.split(':'); return a === b; }), JSON.stringify(kinds));
|
|
144
|
+
ok('no widget failed to draw', !(await page.$(D + ' [data-rp3-fail]')));
|
|
145
|
+
ok('the dashboard says it shows the default layout', /7 reports, the default layout/.test(await page.textContent(D + ' [data-rp3-count]')));
|
|
146
|
+
const pRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
147
|
+
ok('portfolio delivery holds initiatives and tasks', pRows.includes('checkout') && pRows.includes('sso') && pRows.some((r) => r.startsWith('t-')), JSON.stringify(pRows));
|
|
148
|
+
ok('no scope chooser on Portfolio', !(await page.$(D + ' [data-rp3-pick]')));
|
|
149
|
+
|
|
150
|
+
/* ── scopes, the chooser, and one read per scope ── */
|
|
151
|
+
await spy(page);
|
|
152
|
+
await page.click(D + ' [data-rp3-scope="board"]');
|
|
153
|
+
ok('Board shows a chooser of the boards', !!(await until(async () => (await page.$$eval(D + ' [data-rp3-pick="board"] option', (a) => a.map((o) => o.value))).join(',') === 'shop,enterprise')));
|
|
154
|
+
ok('Board draws its five default reports', !!(await loaded(page, 5)), JSON.stringify(await order(page)));
|
|
155
|
+
const bRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
156
|
+
ok('Board Shop: delivery shows only the Shop initiatives', bRows.length > 0 && bRows.every((r) => ['checkout', 'search', 'returns', 'onb'].includes(r)), JSON.stringify(bRows));
|
|
157
|
+
ok('the title names the board', /Board: Shop/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
158
|
+
await page.selectOption(D + ' [data-rp3-pick="board"]', 'enterprise');
|
|
159
|
+
await until(async () => /Enterprise/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
160
|
+
await loaded(page, 5);
|
|
161
|
+
const eRows = await page.$$eval(D + ' [data-rp3-w="delivery"] [data-cs-row]', (a) => a.map((g) => g.getAttribute('data-cs-row')));
|
|
162
|
+
ok('Board Enterprise: delivery shows only SSO', JSON.stringify(eRows) === '["sso"]', JSON.stringify(eRows));
|
|
163
|
+
await page.click(D + ' [data-rp3-scope="initiative"]');
|
|
164
|
+
ok('Initiative shows a chooser of the five initiatives', !!(await until(async () => (await page.$$(D + ' [data-rp3-pick="initiative"] option')).length === 5)));
|
|
165
|
+
await page.selectOption(D + ' [data-rp3-pick="initiative"]', 'sso');
|
|
166
|
+
await until(async () => /Initiative: SSO/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
167
|
+
ok('Initiative draws its four default reports', !!(await loaded(page, 4)), JSON.stringify(await order(page)));
|
|
168
|
+
const sigs = await page.$$eval(D + ' [data-rp3-w="signals"] [data-cs-sig]', (a) => a.map((g) => g.getAttribute('data-cs-sig')).sort());
|
|
169
|
+
ok('Initiative SSO: early signals are only the ones about SSO', JSON.stringify(sigs) === '["dep","ent"]', JSON.stringify(sigs));
|
|
170
|
+
await page.selectOption(D + ' [data-rp3-pick="initiative"]', 'onb');
|
|
171
|
+
await until(async () => /onboarding/.test(await page.textContent(D + ' [data-rp3-title]')));
|
|
172
|
+
await loaded(page, 4);
|
|
173
|
+
ok('a scope with nothing for a report draws that report\'s own empty card', !!(await page.$(D + ' [data-rp3-w="signals"] [data-cs-empty="true"]')));
|
|
174
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
175
|
+
await page.click(D + ' [data-rp3-scope="board"]'); await loaded(page, 5);
|
|
176
|
+
const reads = await page.evaluate(() => window.__rp3spy.reads);
|
|
177
|
+
ok('the data route is read once per scope and id, and not again within the minute', JSON.stringify(reads) === JSON.stringify(['board|shop', 'board|enterprise', 'initiative|checkout', 'initiative|sso', 'initiative|onb']), JSON.stringify(reads));
|
|
178
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
179
|
+
const freeB = await page.$$eval(D + ' [data-rp3-free]', (a) => a.map((n) => n.getAttribute('data-rp3-free')));
|
|
180
|
+
ok('the Board add list holds only reports that fit a board (no north star, no maturity)', !freeB.includes('north') && !freeB.includes('maturity'), JSON.stringify(freeB));
|
|
181
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
182
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
183
|
+
|
|
184
|
+
/* ── edit ── */
|
|
185
|
+
await spy(page);
|
|
186
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
187
|
+
ok('Edit dashboard turns edit mode on, with arrows and Remove on each widget', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'true' && (await page.$$(D + ' [data-rp3-rm]')).length === 7 && (await page.$$(D + ' [data-rp3-up]')).length === 7);
|
|
188
|
+
ok('the first widget cannot move up, the last cannot move down', await page.$eval(D + ' [data-rp3-up="north"]', (b) => b.disabled) && await page.$eval(D + ' [data-rp3-down="maturity"]', (b) => b.disabled));
|
|
189
|
+
ok('Reset to default is off while the scope shows its default', await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled));
|
|
190
|
+
await page.click(D + ' [data-rp3-up="outcomes"]');
|
|
191
|
+
ok('up moves a report up', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(['outcomes', 'north', 'signals', 'delivery', 'bets', 'synergy', 'maturity']))), JSON.stringify(await order(page)));
|
|
192
|
+
let P = await posts(page, '/api/report-dashboards/layout');
|
|
193
|
+
ok('the move POSTs { key: portfolio, reports } with the new order', P.length === 1 && P[0].body.key === 'portfolio' && JSON.stringify(P[0].body.reports) === JSON.stringify(['outcomes', 'north', 'signals', 'delivery', 'bets', 'synergy', 'maturity']), JSON.stringify(P));
|
|
194
|
+
await page.click(D + ' [data-rp3-down="north"]');
|
|
195
|
+
ok('down moves a report down', !!(await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
196
|
+
ok('the dashboard now says it shows your layout', /your layout/.test(await page.textContent(D + ' [data-rp3-count]')));
|
|
197
|
+
await page.click(D + ' [data-rp3-rm="signals"]');
|
|
198
|
+
ok('Remove takes the report off the dashboard', !!(await until(async () => !(await order(page)).includes('signals'))));
|
|
199
|
+
const tt = await until(() => page.$('[data-c3-toast]'));
|
|
200
|
+
ok('Remove shows a toast that says the report stays in the library, with Undo', !!tt && /stays in the library/.test(await tt.textContent()) && !!(await tt.$('[data-c3-toast-action]')));
|
|
201
|
+
ok('the removed report is offered under Add from the report library', !!(await until(() => page.$(D + ' [data-rp3-add="signals"]'))));
|
|
202
|
+
await page.click('[data-c3-toast-action]');
|
|
203
|
+
ok('Undo puts the report back where it was', !!(await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
204
|
+
await page.click(D + ' [data-rp3-rm="maturity"]');
|
|
205
|
+
await until(async () => !(await order(page)).includes('maturity'));
|
|
206
|
+
await page.click(D + ' [data-rp3-add="maturity"]');
|
|
207
|
+
ok('Add puts a library report at the end of the dashboard', !!(await until(async () => (await order(page)).slice(-1)[0] === 'maturity')));
|
|
208
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
209
|
+
ok('every change POSTed its layout (move, move, remove, undo, remove, add)', P.length === 6 && P.every((q) => q.body.key === 'portfolio' && Array.isArray(q.body.reports)), String(P.length));
|
|
210
|
+
ok('Reset to default is on once the scope has its own layout', !(await page.$eval(D + ' [data-rp3-reset]', (b) => b.disabled)));
|
|
211
|
+
// a failed POST: the page keeps the change and says why
|
|
212
|
+
await page.evaluate(() => { window.__rp3spy.fail = '/api/report-dashboards/layout'; });
|
|
213
|
+
await page.click(D + ' [data-rp3-down="outcomes"]');
|
|
214
|
+
ok('a failed POST keeps the change on the page', !!(await until(async () => (await order(page)).join(',') === 'signals,outcomes,north,delivery,bets,synergy,maturity')), JSON.stringify(await order(page)));
|
|
215
|
+
ok('a failed POST is toasted with its reason', !!(await until(async () => { const t = await page.$('[data-c3-toast]'); return t && /not saved: the store is read-only/.test(await t.textContent()); })));
|
|
216
|
+
await page.evaluate(() => { window.__rp3spy.fail = null; });
|
|
217
|
+
await page.click(D + ' [data-rp3-up="outcomes"]');
|
|
218
|
+
await until(async () => (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity');
|
|
219
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
220
|
+
ok('Done editing leaves edit mode', await page.getAttribute(D + ' [data-rp3-edit]', 'aria-pressed') === 'false' && !(await page.$(D + ' [data-rp3-rm]')));
|
|
221
|
+
|
|
222
|
+
/* ── the layout survives a reload ── */
|
|
223
|
+
await open(page); await loaded(page, 7);
|
|
224
|
+
ok('a stored layout is there after a reload', (await order(page)).join(',') === 'outcomes,signals,north,delivery,bets,synergy,maturity', JSON.stringify(await order(page)));
|
|
225
|
+
await spy(page);
|
|
226
|
+
await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-reset]');
|
|
227
|
+
ok('Reset to default returns the default order', !!(await until(async () => JSON.stringify(await order(page)) === JSON.stringify(DEF_P))), JSON.stringify(await order(page)));
|
|
228
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
229
|
+
ok('Reset POSTs reports: null', P.length === 1 && P[0].body.reports === null && P[0].body.key === 'portfolio', JSON.stringify(P));
|
|
230
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
231
|
+
// a board layout is kept per key
|
|
232
|
+
await page.click(D + ' [data-rp3-scope="board"]'); await loaded(page, 5);
|
|
233
|
+
await page.click(D + ' [data-rp3-edit]'); await page.click(D + ' [data-rp3-rm="synergy"]'); await until(async () => (await order(page)).length === 4);
|
|
234
|
+
await page.click(D + ' [data-rp3-edit]');
|
|
235
|
+
P = await posts(page, '/api/report-dashboards/layout');
|
|
236
|
+
ok('a board layout is POSTed under board:<id>', P.length === 2 && P[1].body.key === 'board:shop', JSON.stringify(P.map((q) => q.body.key)));
|
|
237
|
+
await page.click(D + ' [data-rp3-scope="portfolio"]'); await loaded(page, 7);
|
|
238
|
+
ok('the portfolio keeps its own layout when a board changes', JSON.stringify(await order(page)) === JSON.stringify(DEF_P));
|
|
239
|
+
|
|
240
|
+
/* ── share ── */
|
|
241
|
+
await spy(page);
|
|
242
|
+
await page.click(D + ' [data-rp3-share]');
|
|
243
|
+
const sheet = await until(() => page.$('[data-rp3-sheet] [data-rp3-frame="Headline"]'));
|
|
244
|
+
ok('Share opens a sheet with the report frame', !!sheet);
|
|
245
|
+
const frames = await page.$$eval('[data-rp3-sheet] [data-rp3-frame] h4', (a) => a.map((h) => h.textContent));
|
|
246
|
+
ok('the frame has Headline, Sections, Needs your call, Sources', JSON.stringify(frames) === JSON.stringify(['Headline', 'Sections', 'Needs your call', 'Sources']), JSON.stringify(frames));
|
|
247
|
+
ok('Leadership: one sentence per report, no numbers', await page.$eval('[data-rp3-sheet] [data-rp3-aud]', (n) => n.getAttribute('data-rp3-aud')) === 'Leadership'
|
|
248
|
+
&& (await page.$$('[data-rp3-sheet] [data-rp3-frame="Sections"] h5')).length === 7 && !(await page.$('[data-rp3-sheet] [data-rp3-nums]')));
|
|
249
|
+
const need = await page.textContent('[data-rp3-sheet] [data-rp3-frame="Needs your call"]');
|
|
250
|
+
ok('Needs your call lists what the reports flag', /Conversion is off course/.test(need) && /Checkout v2 is delayed/.test(need), need.slice(0, 200));
|
|
251
|
+
await page.click('[data-rp3-sheet] [data-rp3-audbtn="Team"]');
|
|
252
|
+
ok('Team adds the numbers behind each sentence', !!(await until(async () => (await page.$$('[data-rp3-sheet] [data-rp3-nums]')).length >= 5)));
|
|
253
|
+
ok('the Team numbers come from the records (Conversion now 3.1%)', /Conversion: now 3\.1%, should be 3\.75%, target 4%/.test(await page.textContent('[data-rp3-sheet] [data-rp3-nums="outcomes"]')));
|
|
254
|
+
const sched = await page.$('[data-rp3-sheet] [data-rp3-sched]');
|
|
255
|
+
ok('the schedule control is disabled', !!sched && await sched.evaluate((s) => s.disabled));
|
|
256
|
+
ok('the schedule says sending on a schedule is not available, and why', /not available yet, because nothing is sent outward/.test(await page.textContent('[data-rp3-sheet] [data-rp3-schedule]')));
|
|
257
|
+
await page.click('[data-rp3-sheet] [data-rp3-draft]');
|
|
258
|
+
ok('Save as draft says one draft is in the outbox and nothing was sent', !!(await until(async () => /one draft in the outbox\. Nothing was sent/.test(await page.textContent('[data-rp3-sheet] [data-rp3-share-msg]')))));
|
|
259
|
+
ok('Save as draft is off once the draft is saved', await page.$eval('[data-rp3-sheet] [data-rp3-draft]', (b) => b.disabled));
|
|
260
|
+
P = await posts(page, '/api/outbox/draft');
|
|
261
|
+
const B = P[0] && P[0].body;
|
|
262
|
+
ok('ONE draft POST, to a known channel, for the chosen audience, its claim lines labelled', P.length === 1 && B.channel === 'customer-note' && B.recipient === 'Team' && /^Portfolio for Team/.test(B.body)
|
|
263
|
+
&& B.body.split('\n').filter((l) => /^- /.test(l)).every((l) => /\[reported\]$/.test(l)), JSON.stringify(B).slice(0, 300));
|
|
264
|
+
ok('nothing else was POSTed from the sheet (no send, no schedule)', (await page.evaluate(() => window.__rp3spy.posts.length)) === 1);
|
|
265
|
+
await page.keyboard.press('Escape');
|
|
266
|
+
ok('Escape closes the sheet', !!(await until(async () => !(await page.$('[data-rp3-sheet]')))));
|
|
267
|
+
|
|
268
|
+
/* ── library and one home ── */
|
|
269
|
+
ok('the dashboard has no data-home row; every widget carries data-ref-row', !(await page.$(D + ' [data-home]')) && (await page.$$(D + ' article[data-ref-row]')).length === 7);
|
|
270
|
+
await page.click(D + ' [data-rp3-about="outcomes"]');
|
|
271
|
+
ok('a widget opens its case in place (question, source, where used)', !!(await until(async () => { const x = await page.$(D + ' [data-rp3-w="outcomes"] .xp.open'); return x && /Used on: Portfolio/.test(await x.textContent()); })));
|
|
272
|
+
await page.click(D + ' [data-rp3-golib="outcomes"]');
|
|
273
|
+
ok('Open in the library goes to the Library tab', !!(await until(async () => await page.getAttribute('[data-rp3-tab="lib"]', 'aria-selected') === 'true')));
|
|
274
|
+
const L = '[data-rp3-pane="lib"]';
|
|
275
|
+
const libRows = await page.$$eval(L + ' [data-home]', (a) => a.map((r) => r.getAttribute('data-home')));
|
|
276
|
+
ok('the library has one row per report, each its home', libRows.length === 7 && new Set(libRows).size === 7, JSON.stringify(libRows));
|
|
277
|
+
ok('library rows are not references', !(await page.$(L + ' [data-ref-row]')));
|
|
278
|
+
const lrow = await page.textContent(L + ' [data-home="maturity"]');
|
|
279
|
+
ok('a library row shows the question, the source and where it is used', /Where does each pillar stand/.test(lrow) && /Pillar maturity records/.test(lrow) && /Used on: Portfolio/.test(lrow), lrow.slice(0, 200));
|
|
280
|
+
ok('the row opened from the widget is open, with its card', !!(await until(() => page.$(L + ' [data-home="outcomes"][aria-expanded="true"] [data-cs="outcomes"]'))));
|
|
281
|
+
await page.click(L + ' [data-home="bets"]');
|
|
282
|
+
ok('a library row opens the report\'s card in place', !!(await until(() => page.$(L + ' [data-home="bets"] .xp.open [data-cs="bets"]'))));
|
|
283
|
+
ok('a board layout shows in where it is used', /Board dashboards by default/.test(await page.textContent(L + ' [data-home="synergy"]')));
|
|
284
|
+
|
|
285
|
+
/* ── drafts ── */
|
|
286
|
+
await page.click('[data-rp3-tab="drafts"]');
|
|
287
|
+
ok('the Drafts tab shows the report families', !!(await until(() => page.$('[data-rp3-pane="drafts"] [data-report-families] [data-family]'), 15000)));
|
|
288
|
+
ok('the dashboards are hidden while Drafts is open', await page.$eval(D, (n) => n.hidden));
|
|
289
|
+
await page.click('[data-rp3-tab="dash"]');
|
|
290
|
+
|
|
291
|
+
/* ── phone ── */
|
|
292
|
+
const ph = await browser.newContext({ viewport: { width: 400, height: 860 }, hasTouch: true, isMobile: true });
|
|
293
|
+
const pp = await ph.newPage(); watch(pp);
|
|
294
|
+
await open(pp); await loaded(pp, 7);
|
|
295
|
+
const noScroll = (pg) => pg.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1);
|
|
296
|
+
ok('400px: no horizontal page scroll on the dashboard', await noScroll(pp), String(await pp.evaluate(() => document.documentElement.scrollWidth)));
|
|
297
|
+
const lefts = await pp.$$eval(D + ' [data-rp3-w]', (a) => [...new Set(a.map((w) => Math.round(w.getBoundingClientRect().left)))]);
|
|
298
|
+
ok('400px: the widgets stack in one column', lefts.length === 1, JSON.stringify(lefts));
|
|
299
|
+
await pp.click(D + ' [data-rp3-scope="initiative"]'); await loaded(pp, 4);
|
|
300
|
+
await pp.click(D + ' [data-rp3-edit]');
|
|
301
|
+
ok('400px: Initiative scope in edit mode has no horizontal scroll', await noScroll(pp));
|
|
302
|
+
await pp.click(D + ' [data-rp3-edit]');
|
|
303
|
+
await pp.click(D + ' [data-rp3-share]'); await until(() => pp.$('[data-rp3-sheet] [data-rp3-frame]'));
|
|
304
|
+
ok('400px: the share sheet has no horizontal scroll', await noScroll(pp));
|
|
305
|
+
await pp.keyboard.press('Escape');
|
|
306
|
+
await pp.click('[data-rp3-tab="lib"]'); await pp.click('[data-rp3-pane="lib"] [data-home="delivery"]');
|
|
307
|
+
await until(() => pp.$('[data-rp3-pane="lib"] [data-home="delivery"] .xp.open [data-cs="delivery"]'));
|
|
308
|
+
ok('400px: the library with an open row has no horizontal scroll', await noScroll(pp));
|
|
309
|
+
await ph.close();
|
|
310
|
+
|
|
311
|
+
await sleep(300); // a late error from a closing view would land here
|
|
312
|
+
ok('no script error and no console error', errors.length === 0, errors.slice(0, 4).join(' | '));
|
|
313
|
+
return finish();
|
|
314
|
+
})();
|
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
"work"
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
"title": "cut to 3 flows and hold 24 oct",
|
|
41
|
+
"tabs": [
|
|
42
|
+
"init",
|
|
43
|
+
"rep"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"title": "freeze scope after today's decision",
|
|
48
|
+
"tabs": [
|
|
49
|
+
"init",
|
|
50
|
+
"rep"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
39
53
|
{
|
|
40
54
|
"title": "pick interview slots with 3 customers",
|
|
41
55
|
"tabs": [
|
|
@@ -43,6 +57,13 @@
|
|
|
43
57
|
"work"
|
|
44
58
|
]
|
|
45
59
|
},
|
|
60
|
+
{
|
|
61
|
+
"title": "pull returns forward a quarter if interview 5 agrees",
|
|
62
|
+
"tabs": [
|
|
63
|
+
"init",
|
|
64
|
+
"rep"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
46
67
|
{
|
|
47
68
|
"title": "review the 13:00 interview guide",
|
|
48
69
|
"tabs": [
|
|
@@ -50,6 +71,13 @@
|
|
|
50
71
|
"work"
|
|
51
72
|
]
|
|
52
73
|
},
|
|
74
|
+
{
|
|
75
|
+
"title": "run the address-step experiment, freeze scope",
|
|
76
|
+
"tabs": [
|
|
77
|
+
"init",
|
|
78
|
+
"rep"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
53
81
|
{
|
|
54
82
|
"title": "say yes to sam: ship the logging change",
|
|
55
83
|
"tabs": [
|
|
@@ -68,15 +96,7 @@
|
|
|
68
96
|
"title": "sso is live with 3 customers",
|
|
69
97
|
"tabs": [
|
|
70
98
|
"brief",
|
|
71
|
-
"init"
|
|
72
|
-
"rep"
|
|
73
|
-
]
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"title": "the board update is 70% ready",
|
|
77
|
-
"tabs": [
|
|
78
|
-
"init",
|
|
79
|
-
"rep"
|
|
99
|
+
"init"
|
|
80
100
|
]
|
|
81
101
|
}
|
|
82
102
|
]
|
package/workspace/guide/index.md
CHANGED
|
@@ -62,3 +62,4 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
62
62
|
- [Board](board.md): one Kanban for the items of every initiative, its filters, Release, and `pmos board`.
|
|
63
63
|
- [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
|
|
64
64
|
- [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
|
|
65
|
+
- [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit and share one as a draft, and the Drafts tab.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: layout"
|
|
4
4
|
description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and lists; the keys; and the older layout."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-11
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: layout
|
|
@@ -51,7 +51,7 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
|
|
|
51
51
|
|
|
52
52
|
- Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
|
|
53
53
|
- The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
|
|
54
|
-
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false)
|
|
54
|
+
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays for a day. When a schedule starts a new run, it ends its previous run first, unless that run is still working or waits for your answer. Any session that has ended, also one you started, is removed after 7 days. Its conversation is kept when it ends. A session you started is never ended for you. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false), `idleMinutes` (5 or more, or 0 for off) and `keepExitedDays` (1 to 365, or 0 to keep ended sessions). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
|
|
55
55
|
- Each session row starts with a mark for its state: ● working, ◼ quiet, ◆ needs you, ◇ maybe blocked, ○ ended. Under the name, the row shows the state when the session is not working, the type of work when one is set, and the branch when it is not main and not the initiative's own branch. Running sessions come first and ended sessions last. Press the row to bring the session to the front.
|
|
56
56
|
- Press the name of a group to fold it or open it. The number beside it counts its sessions. **Automatic** is folded while none of its sessions is running, because each run leaves an ended session. The group of the session in front always stays open, and the workspace keeps your choice for each group.
|
|
57
57
|
- **⋯** and **✕** show on the row in front, on the row under the pointer and on the row with the keyboard focus. On a touch screen they always show.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Reports: dashboards of reports"
|
|
4
|
+
description: "How the console Reports page shows dashboards at portfolio, board and initiative scope, how to edit one, how to share one as a draft, and where the report drafts went."
|
|
5
|
+
timestamp: 2026-10-11
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Reports: dashboards of reports
|
|
9
|
+
|
|
10
|
+
A report answers one question. A dashboard is a set of reports for one scope. Open the Reports tab of the console to see them.
|
|
11
|
+
|
|
12
|
+
## The three tabs
|
|
13
|
+
|
|
14
|
+
- **Dashboards.** The reports of one scope, drawn from the repository's records.
|
|
15
|
+
- **Library.** Every report the workspace can draw, with the question it answers and where it is used.
|
|
16
|
+
- **Drafts.** The four report families, the claims check and the outbox. They work as before.
|
|
17
|
+
|
|
18
|
+
## Choose a scope
|
|
19
|
+
|
|
20
|
+
Press **Portfolio**, **Board** or **Initiative**.
|
|
21
|
+
|
|
22
|
+
- **Portfolio** covers every initiative.
|
|
23
|
+
- **Board** covers the initiatives of one board. A board is a domain. Choose it from the list beside the scope.
|
|
24
|
+
- **Initiative** covers one initiative. Choose it from the list.
|
|
25
|
+
|
|
26
|
+
A report shows only the rows that belong to the scope. A report with no rows in the scope shows an empty card. The card says what is missing and what fills it. The workspace never invents a number.
|
|
27
|
+
|
|
28
|
+
## The reports
|
|
29
|
+
|
|
30
|
+
| Report | The question it answers |
|
|
31
|
+
|---|---|
|
|
32
|
+
| North star | Is the north-star metric on its plan? |
|
|
33
|
+
| Quarter outcomes | Where is each key result, where should it be, and where will it land? |
|
|
34
|
+
| Early signals | What is drifting before the numbers say so? |
|
|
35
|
+
| Delivery against promise | Which promised dates will hold? |
|
|
36
|
+
| Maturity by pillar | Where does each pillar stand against its target? |
|
|
37
|
+
| Bets and their checkpoints | When does each bet persevere or pivot? |
|
|
38
|
+
| Synergy and coverage | Do the bets add up to the key results? |
|
|
39
|
+
|
|
40
|
+
The Calendar page no longer carries these. They moved here.
|
|
41
|
+
|
|
42
|
+
## Edit a dashboard
|
|
43
|
+
|
|
44
|
+
1. Press **Edit dashboard**.
|
|
45
|
+
2. Use the arrows to move a report. Press **Remove** to take one off. The report stays in the library, and **Undo** brings it back.
|
|
46
|
+
3. Choose a report under **Add from the report library**.
|
|
47
|
+
4. Press **Done editing**.
|
|
48
|
+
|
|
49
|
+
The workspace keeps your layout for each scope in its runtime state. It does not write to the repository. **Reset to default** returns a scope to its first layout.
|
|
50
|
+
|
|
51
|
+
## Share a dashboard
|
|
52
|
+
|
|
53
|
+
Press **Share**. A sheet shows the dashboard as a report: a headline, one section per report, what needs your call, and the sources. Choose **Leadership** for one sentence per report. Choose **Team** to add the numbers behind each sentence.
|
|
54
|
+
|
|
55
|
+
**Save as draft** puts one draft in the outbox. Nothing is sent. With no channel connected, the outbox says so.
|
|
56
|
+
|
|
57
|
+
Scheduled sending is not available yet. The schedule control is off, and the sheet says why.
|
|
@@ -251,6 +251,24 @@ function removeWorktree(root, dir) {
|
|
|
251
251
|
return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// { ok, current, detached } — only the HEAD half of branchInfo, with the same failure codes. The
|
|
255
|
+
// workspace list and the session heartbeat need the branch name and nothing else; branchInfo also
|
|
256
|
+
// lists every branch, runs `git status --porcelain` and lists the worktrees, which on a large repo
|
|
257
|
+
// is most of the cost and was paid for every workspace every 15 seconds. `top` may be passed when
|
|
258
|
+
// the caller already resolved the repo root, saving one more shell-out.
|
|
259
|
+
function headInfo(root, top) {
|
|
260
|
+
const t = top === undefined ? repoRoot(root) : top;
|
|
261
|
+
if (!t) {
|
|
262
|
+
if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
|
|
263
|
+
return { ok: false, code: 'git-error', error: 'git not available' };
|
|
264
|
+
}
|
|
265
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
266
|
+
if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
|
|
267
|
+
if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
|
|
268
|
+
const detached = head.out === 'HEAD';
|
|
269
|
+
return { ok: true, current: detached ? null : head.out, detached };
|
|
270
|
+
}
|
|
271
|
+
|
|
254
272
|
// { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
|
|
255
273
|
// or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
|
|
256
274
|
// is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
|
|
@@ -284,5 +302,5 @@ function branchInfo(root) {
|
|
|
284
302
|
};
|
|
285
303
|
}
|
|
286
304
|
|
|
287
|
-
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
|
|
305
|
+
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, headInfo, checkout, worktrees, worktreePath,
|
|
288
306
|
addWorktree, worktreeSafety, removeWorktree, originUrl, isGithubRemote };
|