create-pmos 0.38.7 → 0.39.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/README.md +64 -1
- package/workspace/bin/pmos.js +10 -1
- package/workspace/guide/index.md +38 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +59 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +47 -0
- package/workspace/lib/initiative-files.js +134 -0
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +133 -0
- package/workspace/lib/prompt-input.js +18 -0
- package/workspace/lib/schedule-cli.js +259 -0
- package/workspace/lib/schedule-logic.js +23 -1
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/skill-store.js +11 -3
- package/workspace/lib/status-groups.js +109 -0
- package/workspace/lib/transcripts.js +73 -0
- package/workspace/lib/typesafe-settings.js +119 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +2 -0
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +41 -5
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +171 -0
- package/workspace/public/pm/modes.js +25 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +121 -1
- package/workspace/public/pm/panel-initiatives.js +61 -3
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/panel-tabs.js +38 -2
- package/workspace/public/pm/panel-trees.js +9 -2
- package/workspace/public/pm/state.js +9 -4
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +11 -6
- package/workspace/server.js +434 -8
|
@@ -265,15 +265,19 @@ export function openTerm(slug, sess) {
|
|
|
265
265
|
});
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
export function openDoc(slug, path, role, tree) {
|
|
268
|
+
export function openDoc(slug, path, role, tree, opts) {
|
|
269
269
|
/* `tree` — the line of work a search hit came from (overhaul-r1 R1.3). A hit on a LINKED worktree
|
|
270
270
|
* opens a tab that carries that tree's `ws` and `branch`, under its own id, so it is read from and
|
|
271
271
|
* saved to that tree and can sit beside the main tree's copy of the same path. A main-tree hit
|
|
272
272
|
* opens exactly the tab it always did. */
|
|
273
273
|
const linked = !!(tree && tree.ws && tree.linked);
|
|
274
|
+
// `opts.ref` — read the file from that initiative branch through git (it is not in the checked-out tree);
|
|
275
|
+
// `opts.readonly` — a git file is evidence, so the tab offers no edit or save.
|
|
276
|
+
const ref = opts && opts.ref ? opts.ref : null;
|
|
274
277
|
openTab({
|
|
275
|
-
id: 'doc:' + (linked ? tree.ws + ':' : '') + path, kind: 'doc', title: path.split('/').pop(), slug,
|
|
278
|
+
id: 'doc:' + (linked ? tree.ws + ':' : '') + (ref ? 'ref:' + ref + ':' : '') + path, kind: 'doc', title: path.split('/').pop(), slug,
|
|
276
279
|
...(linked ? { ws: tree.ws, branch: tree.branch || null } : {}),
|
|
280
|
+
...(ref ? { ref } : {}), ...(opts && opts.readonly ? { readonly: true } : {}),
|
|
277
281
|
/* A file opened from the FOLDER search belongs to no initiative, so `slugProduct()` has no
|
|
278
282
|
* answer for it — and `panel-tabs.visibleTabs` shows a tab whose repo is `undefined` in EVERY
|
|
279
283
|
* repository, which would hang this workspace's file in another one's tab strip and read its
|
|
@@ -828,7 +832,61 @@ export function renderInitiatives(host) {
|
|
|
828
832
|
|
|
829
833
|
const hits = searchHits(q, DATA, live);
|
|
830
834
|
|
|
831
|
-
|
|
835
|
+
/* GROUP BY STATUS (initiative-surface-batch R2). The status is the one the server DERIVES (deriveState + the
|
|
836
|
+
* lifecycle ledger), mapped to the PM's words by lib/status-groups.js; nothing typed in a record decides where a
|
|
837
|
+
* row lands. A failed read of the statuses is said under the control and everything files under Unknown. */
|
|
838
|
+
const SG = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
|
|
839
|
+
const byStatus = state.groupBy === 'status' && !!SG;
|
|
840
|
+
const statusRows = (state.statuses && state.statuses[product]) || {};
|
|
841
|
+
const statusErr = (state.statusErrors && state.statusErrors[product]) || null;
|
|
842
|
+
const STATUS_TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', planning: 'var(--muted-foreground)',
|
|
843
|
+
'awaiting-acceptance': 'var(--foreground)', accepted: 'var(--muted-foreground)', 'on-hold': 'var(--muted-foreground)',
|
|
844
|
+
closed: 'var(--muted-foreground)', cancelled: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
|
|
845
|
+
if (byStatus) {
|
|
846
|
+
const seen = new Set(); const all = [];
|
|
847
|
+
for (const g of groupsFor(product)) for (const sl of g.slugs) if (!seen.has(sl)) { seen.add(sl); all.push(sl); }
|
|
848
|
+
const shown = all.filter((sl) => !q || sl.includes(q));
|
|
849
|
+
// An active search shows every match whatever its status: the "active" filter must not hide a hit.
|
|
850
|
+
const sgs = SG.group(shown, statusRows, { activeOnly: !!state.activeOnly && !q });
|
|
851
|
+
total = 0;
|
|
852
|
+
groups.length = 0;
|
|
853
|
+
for (const sg of sgs) {
|
|
854
|
+
const inits = sg.slugs.map((sl) => rowOf(sl, null));
|
|
855
|
+
total += inits.length;
|
|
856
|
+
groups.push({ gid: 'status:' + sg.key, idc: STATUS_TONE[sg.key] || 'var(--muted-foreground)', gname: sg.label, gct: String(inits.length),
|
|
857
|
+
hasKr: true, krid: '●', krname: sg.meaning || sg.hint, krct: String(inits.length), slugs: sg.slugs, inits, isEmpty: false });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:2px 8px;border:1px solid var(--border);min-height:24px';
|
|
861
|
+
const chip = (label, on, onClick, attr) => el('button', { onClick, 'data-r16': attr,
|
|
862
|
+
style: CHIP + ';' + (on ? 'color:var(--foreground);background:var(--secondary);border-color:var(--input)' : 'color:var(--muted-foreground)'),
|
|
863
|
+
hover: 'color:var(--foreground)', 'aria-pressed': on ? 'true' : 'false' }, label);
|
|
864
|
+
const groupBar = FIXTURE ? null : el('div', { 'data-r16': 'group-bar', style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;padding:4px 12px 6px' },
|
|
865
|
+
el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-foreground)' }, 'group by'),
|
|
866
|
+
chip('objective', !byStatus, () => setState({ groupBy: 'objective' }), 'by-objective'),
|
|
867
|
+
chip('status', byStatus, () => setState({ groupBy: 'status' }), 'by-status'),
|
|
868
|
+
byStatus ? chip('active only', !!state.activeOnly, () => setState({ activeOnly: !state.activeOnly }), 'active-only') : null,
|
|
869
|
+
SG ? chip('what do these mean?', !!state.legendOpen, () => setState({ legendOpen: !state.legendOpen }), 'legend-toggle') : null,
|
|
870
|
+
byStatus && statusErr ? el('span', { 'data-r16': 'status-error', style: 'flex-basis:100%;font-size:10.5px;color:var(--destructive)' }, 'status could not be read — ' + statusErr + ' (every initiative is under Unknown)') : null);
|
|
871
|
+
|
|
872
|
+
/* THE LEGEND (status-clarity R1). One row per group, read from the same table that files the rows, so what this
|
|
873
|
+
* says and where a row lands cannot disagree: what it means, the fact it is read from, who acts, what moves it,
|
|
874
|
+
* and what the PM can do by hand. */
|
|
875
|
+
const OWNER_WORD = { pm: 'you', agent: 'the agent', system: 'nobody (derived)' };
|
|
876
|
+
const legend = !FIXTURE && SG && state.legendOpen ? el('div', { 'data-r18': 'legend',
|
|
877
|
+
style: 'display:flex;flex-direction:column;gap:8px;margin:0 12px 8px;padding:8px 10px;border:1px solid var(--border);background:var(--secondary)' },
|
|
878
|
+
el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5' },
|
|
879
|
+
'A status is never typed. It is worked out again each time from the ledgers and git. You can only hold, close, cancel or reopen an initiative by hand.'),
|
|
880
|
+
SG.GROUPS.map((g) => el('div', { 'data-r18': 'legend-row', 'data-key': g.key, style: 'display:flex;flex-direction:column;gap:2px;padding-top:6px;border-top:1px solid var(--border)' },
|
|
881
|
+
el('div', { style: 'display:flex;gap:7px;align-items:baseline' },
|
|
882
|
+
el('span', { style: 'font-size:12px;font-weight:600;color:' + (STATUS_TONE[g.key] || 'var(--foreground)') }, g.label),
|
|
883
|
+
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'acts: ' + (OWNER_WORD[g.owner] || g.owner))),
|
|
884
|
+
el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, g.meaning),
|
|
885
|
+
el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'read from: ' + g.source),
|
|
886
|
+
el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'moves: ' + g.moves),
|
|
887
|
+
el('div', { style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' }, 'you can: ' + g.pmCan)))) : null;
|
|
888
|
+
|
|
889
|
+
const kids = [header, searchBox, groupBar, legend];
|
|
832
890
|
/* THE WORKING SET, pinned above the Objectives.
|
|
833
891
|
*
|
|
834
892
|
* It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
|
|
@@ -177,8 +177,8 @@ export const UNBACKED_MODES = ['agent', 'connect']; // mode ids (labels: agent
|
|
|
177
177
|
/* overhaul-r4 — the PORTFOLIO mode joins "all repositories" in live mode only (the fixture drawer is the
|
|
178
178
|
* prototype's, and the design comparison reads it). Its meta lives here, not in fixture.js, for the
|
|
179
179
|
* same reason. */
|
|
180
|
-
const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'] });
|
|
181
|
-
const LIVE_EXTRA = { 'all repositories': ['portfolio'] };
|
|
180
|
+
const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'], guide: ['?', 'guide'], validate: ['◉', 'validate'] });
|
|
181
|
+
const LIVE_EXTRA = { 'all repositories': ['portfolio', 'guide', 'validate'] };
|
|
182
182
|
const DRAWER_GROUPS = SETTINGS_GROUPS.map((g) => ({
|
|
183
183
|
label: g.label,
|
|
184
184
|
items: (FIXTURE ? [] : LIVE_EXTRA[g.label] || []).concat(
|
|
@@ -42,7 +42,7 @@ import { boardOf, currentProduct } from './board-read.js';
|
|
|
42
42
|
let termMod = null;
|
|
43
43
|
let liveMod = null;
|
|
44
44
|
/* Loaded lazily; a repaint is asked for only when a skill tab is already open (restored on reload) and waiting for it. */
|
|
45
|
-
import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill')) setState({}); }).catch(() => { liveMod = null; });
|
|
45
|
+
import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill' || t.kind === 'transcript')) setState({}); }).catch(() => { liveMod = null; });
|
|
46
46
|
import('./tab-term.js')
|
|
47
47
|
.then((m) => { termMod = m; setState({}); })
|
|
48
48
|
.catch(() => { termMod = null; });
|
|
@@ -144,6 +144,40 @@ export function closeTab(id) {
|
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The terminal tabs attached to ONE session, by tab id (initiative-surface-batch R1).
|
|
149
|
+
*
|
|
150
|
+
* A terminal tab carries the SERVER's session id (`sessionId`), which is the exact join. Read it BEFORE the
|
|
151
|
+
* server is asked to end the session, not after: the moment the server drops the session it pushes the new
|
|
152
|
+
* list, and a tab whose id is no longer listed re-joins by NAME (tab-term.js `resolveSessionId`) — so when two
|
|
153
|
+
* sessions share a name the doomed tab quietly adopts its sibling and starts reporting the sibling's id, and a
|
|
154
|
+
* lookup made after the answer finds nothing to close.
|
|
155
|
+
*/
|
|
156
|
+
export function tabIdsOfSession(sessionId) {
|
|
157
|
+
if (!sessionId) return [];
|
|
158
|
+
return (state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId === sessionId).map((t) => t.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Close tabs by id. Called only after the server said the session is gone; closing a tab by hand still only
|
|
163
|
+
* detaches. Another session's tab, and a tab that is not in `ids`, are untouched. Returns how many closed.
|
|
164
|
+
*/
|
|
165
|
+
export function closeTabsById(ids) {
|
|
166
|
+
const doomed = (state.tabs || []).filter((t) => t && (ids || []).includes(t.id)).map((t) => t.id);
|
|
167
|
+
if (!doomed.length) return 0;
|
|
168
|
+
setState((s) => {
|
|
169
|
+
const rest = s.tabs.filter((t) => !doomed.includes(t.id));
|
|
170
|
+
return {
|
|
171
|
+
tabs: rest,
|
|
172
|
+
active: doomed.includes(s.active) ? (rest.length ? rest[rest.length - 1].id : null) : s.active,
|
|
173
|
+
splitId: doomed.includes(s.splitId) ? null : s.splitId,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
return doomed.length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const closeTabsOfSession = (sessionId) => closeTabsById(tabIdsOfSession(sessionId));
|
|
180
|
+
|
|
147
181
|
/* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
|
|
148
182
|
* mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
|
|
149
183
|
let dataMod = null;
|
|
@@ -797,6 +831,8 @@ function paneFor(tab) {
|
|
|
797
831
|
mountTask(host, tab);
|
|
798
832
|
} else if (tab.kind === 'skill') {
|
|
799
833
|
if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
|
|
834
|
+
} else if (tab.kind === 'transcript') {
|
|
835
|
+
if (liveMod && typeof liveMod.mountTranscriptTab === 'function') liveMod.mountTranscriptTab(host, tab);
|
|
800
836
|
} else if (tab.kind === 'web') {
|
|
801
837
|
mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
|
|
802
838
|
} else {
|
|
@@ -913,7 +949,7 @@ function tabNode(t, act, splitTab) {
|
|
|
913
949
|
* fixture render keeps the design's literal — the comparator reads this text node. */
|
|
914
950
|
const meta = t.kind === 'term'
|
|
915
951
|
? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
|
|
916
|
-
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.role);
|
|
952
|
+
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
|
|
917
953
|
|
|
918
954
|
return el('div', {
|
|
919
955
|
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
@@ -23,10 +23,11 @@ const MONO = 'font-family:var(--mono);font-size:10.5px';
|
|
|
23
23
|
const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
|
|
24
24
|
const MUTED = 'color:var(--muted-foreground)';
|
|
25
25
|
|
|
26
|
-
let dataMod = null; let termMod = null; let iniMod = null;
|
|
26
|
+
let dataMod = null; let termMod = null; let iniMod = null; let tabsMod = null;
|
|
27
27
|
import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
28
28
|
import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
|
|
29
29
|
import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
|
|
30
|
+
import('./panel-tabs.js').then((m) => { tabsMod = m; }).catch(() => { tabsMod = null; });
|
|
30
31
|
|
|
31
32
|
const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
|
|
32
33
|
const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
|
|
@@ -62,10 +63,16 @@ export async function startHere(name, t) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
async function endSession(name, s) {
|
|
66
|
+
// Which tabs are on this session, read BEFORE asking: once the server drops it, a tab re-joins by name and stops saying.
|
|
67
|
+
const tabIds = tabsMod && typeof tabsMod.tabIdsOfSession === 'function' ? tabsMod.tabIdsOfSession(s.id) : [];
|
|
65
68
|
const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
|
|
66
69
|
setState({ treeConfirm: null });
|
|
67
70
|
if (!out || out.ok === false) { note(name, (out && out.error) || 'the session could not be removed', 'bad'); return; }
|
|
68
|
-
|
|
71
|
+
// The session is gone: the tab attached to it has nothing left to show, so it closes too (R1). A refusal returned
|
|
72
|
+
// above and leaves the tab open beside the reason; so does "asked it to end, not exited yet" — the process is still
|
|
73
|
+
// there, and pressing again removes it and closes the tab.
|
|
74
|
+
const closed = out.removed !== false && tabsMod && typeof tabsMod.closeTabsById === 'function' ? tabsMod.closeTabsById(tabIds) : 0;
|
|
75
|
+
note(name, (out.removed === false ? (out.note || 'asked it to end') : 'session removed') + (closed ? ' — its tab was closed' : ''));
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
async function removeTree(name, t) {
|
|
@@ -24,7 +24,7 @@ const STORE_KEY = 'pmos.workspace.pm.v1';
|
|
|
24
24
|
/** The initial state — the prototype's `state = {…}` block, key for key. */
|
|
25
25
|
function initial() {
|
|
26
26
|
return {
|
|
27
|
-
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, settingsOpen: false,
|
|
27
|
+
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, legendOpen: false, settingsOpen: false,
|
|
28
28
|
product: null, intgSynced: {},
|
|
29
29
|
addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
|
|
30
30
|
addLog: [], addedProducts: [], extraKnow: {},
|
|
@@ -153,7 +153,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
|
|
|
153
153
|
// a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
|
|
154
154
|
'url',
|
|
155
155
|
// a skill tab's skill id — a reload reopens the same skill
|
|
156
|
-
'skillId'
|
|
156
|
+
'skillId',
|
|
157
|
+
// a conversation tab's session id — a reload reopens the same transcript
|
|
158
|
+
'convId'];
|
|
157
159
|
const MAX_TABS = 40;
|
|
158
160
|
|
|
159
161
|
function packTab(t) {
|
|
@@ -174,13 +176,16 @@ const isTabList = (v) =>
|
|
|
174
176
|
const PERSIST = {
|
|
175
177
|
theme: (v) => v === 'dark' || v === 'light',
|
|
176
178
|
col0: isBool, col1: isBool, col2: isBool,
|
|
177
|
-
// the dragged
|
|
179
|
+
// the dragged width of the navigation column (clamped again by app.js on the way out). `col1` now means "the list under the
|
|
180
|
+
// repositories is open"; the old second-column width (col1W) is no longer stored and an old value is ignored on load.
|
|
178
181
|
col0W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
|
|
179
|
-
col1W: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 100 && v <= 1200,
|
|
180
182
|
settingsOpen: isBool,
|
|
181
183
|
product: isSlug,
|
|
182
184
|
sel: isSlug,
|
|
183
185
|
collapsedGroups: isFlagMap,
|
|
186
|
+
// how the initiative list is grouped: by objective (default) or by derived status (initiative-surface-batch R2)
|
|
187
|
+
groupBy: (v) => v === 'objective' || v === 'status',
|
|
188
|
+
activeOnly: isBool,
|
|
184
189
|
collapsedPhases: isFlagMap,
|
|
185
190
|
// per doc tab: true = 72ch measure, false = full width; absent = the viewport default (R5.1)
|
|
186
191
|
docNarrow: isFlagMap,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-banner.js — status-clarity R2/R3/R4. The block at the top of an initiative that says where it stands.
|
|
3
|
+
*
|
|
4
|
+
* It states, from facts the server already derived: the group, why (the evidence behind it), who acts, the one
|
|
5
|
+
* thing to do next, how the status will change, and, for "needs you", each open item by its own words. It adds
|
|
6
|
+
* no fact of its own: the group text comes from lib/status-groups.js, the evidence from deriveState, the steps
|
|
7
|
+
* from lib/next-steps.js. A failed read says so and shows no group.
|
|
8
|
+
*
|
|
9
|
+
* The lifecycle control (hold, close, cancel, reopen) sits beside it with one sentence on what it does and the
|
|
10
|
+
* history of every change. Acceptance is the PM's and is recorded in a session, not by a button here (D12), so the
|
|
11
|
+
* banner says how and offers the list of what is waiting.
|
|
12
|
+
*/
|
|
13
|
+
import { el } from './dom.js';
|
|
14
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
15
|
+
|
|
16
|
+
const SG = () => (typeof window !== 'undefined' ? window.PMOSStatusGroups : null);
|
|
17
|
+
const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
18
|
+
const TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', 'awaiting-acceptance': 'var(--foreground)', unknown: 'var(--destructive)' };
|
|
19
|
+
const WHO = { pm: 'you', agent: 'the agent', system: 'nobody — it is derived' };
|
|
20
|
+
const iv = (t) => el('span', null, t == null ? '' : String(t));
|
|
21
|
+
|
|
22
|
+
function line(label, value, attr) {
|
|
23
|
+
return el('div', { style: 'display:grid;grid-template-columns:78px 1fr;gap:8px;padding:2px 0' },
|
|
24
|
+
el('span', { style: LAB + ';padding-top:2px' }, label),
|
|
25
|
+
el('span', { 'data-r18': attr, style: 'font-size:12px;line-height:1.5;color:var(--foreground);overflow-wrap:anywhere' }, iv(value)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @returns {HTMLElement|null} null in fixture mode (the prototype has no such block). */
|
|
29
|
+
export function statusBanner(product, slug, rec) {
|
|
30
|
+
const sg = SG();
|
|
31
|
+
if (FIXTURE || !sg || !slug) return null;
|
|
32
|
+
const err = state.statusErrors && state.statusErrors[product];
|
|
33
|
+
const row = state.statuses && state.statuses[product] && state.statuses[product][slug];
|
|
34
|
+
if (err || !row) {
|
|
35
|
+
return el('div', { 'data-r18': 'status-banner', 'data-key': 'unreadable', style: 'padding:8px 14px;border-bottom:1px solid var(--border)' },
|
|
36
|
+
el('span', { style: LAB }, 'status'),
|
|
37
|
+
el('div', { 'data-r18': 'status-error', style: 'font-size:12px;color:var(--destructive);line-height:1.5' },
|
|
38
|
+
'The status could not be read — ' + (err || 'the server returned no status for this initiative') + '. No group is shown because none can be derived.'));
|
|
39
|
+
}
|
|
40
|
+
const key = sg.keyOf(row);
|
|
41
|
+
const g = sg.BY_KEY[key];
|
|
42
|
+
const items = (rec && rec.nextSteps && rec.nextSteps.items) || [];
|
|
43
|
+
const waiting = items.filter((i) => i.kind === 'needs' || i.kind === 'returned');
|
|
44
|
+
// The one thing to do next: the engine's first item, unless it is not yours and something is waiting on you.
|
|
45
|
+
const mine = items.filter((i) => i.owner === 'pm' && !i.held);
|
|
46
|
+
const first = items.find((i) => i.first) || items[0] || null;
|
|
47
|
+
const next = g.owner === 'pm' ? (mine[0] || first) : first;
|
|
48
|
+
const nextText = next ? next.text + ' — ' + next.action : (g.key === 'accepted' ? 'Nothing remains.' : 'No step is outstanding.');
|
|
49
|
+
|
|
50
|
+
const kids = [
|
|
51
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
|
|
52
|
+
el('span', { style: LAB }, 'status'),
|
|
53
|
+
el('span', { 'data-r18': 'status-group', style: 'font-size:13px;font-weight:600;color:' + (TONE[key] || 'var(--foreground)') }, g.label),
|
|
54
|
+
el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'derived, not typed')),
|
|
55
|
+
line('why', row.statusText ? row.statusText + '. ' + g.meaning : g.meaning, 'status-why'),
|
|
56
|
+
line('acts now', WHO[g.owner] || g.owner, 'status-owner'),
|
|
57
|
+
line('next', nextText, 'status-next'),
|
|
58
|
+
line('changes when', g.moves, 'status-moves'),
|
|
59
|
+
];
|
|
60
|
+
if (waiting.length) {
|
|
61
|
+
kids.push(el('div', { 'data-r18': 'needs-items', style: 'display:flex;flex-direction:column;gap:2px;padding-top:4px' },
|
|
62
|
+
el('span', { style: LAB }, 'waiting on you · ' + waiting.length),
|
|
63
|
+
waiting.map((w) => el('div', { 'data-r18': 'needs-item', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-left:10px;border-left:2px solid var(--destructive)' },
|
|
64
|
+
iv(w.text + (w.action ? ' — ' + w.action : ''))))));
|
|
65
|
+
}
|
|
66
|
+
if (key === 'awaiting-acceptance' || key === 'needs-you') {
|
|
67
|
+
kids.push(el('div', { 'data-r18': 'acceptance-note', style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground);padding-top:4px' },
|
|
68
|
+
'Acceptance is yours and cannot be delegated (D12). Tell the session that holds this initiative to record it, or run scripts/record-acceptance.js. The workspace never records one for you.'),
|
|
69
|
+
el('button', { 'data-r18': 'see-waiting', onClick: () => setState({ mode: 'focus', settingsOpen: false, col1: true }),
|
|
70
|
+
style: 'align-self:flex-start;font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin-top:4px' },
|
|
71
|
+
'see everything waiting on you'));
|
|
72
|
+
}
|
|
73
|
+
return el('div', { 'data-r18': 'status-banner', 'data-key': key, style: 'display:flex;flex-direction:column;gap:2px;padding:8px 14px;border-bottom:1px solid var(--border)' }, kids);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the lifecycle control does, and every change made with it. Sits under the control row. */
|
|
77
|
+
export function lifecycleNotes(rec) {
|
|
78
|
+
if (FIXTURE) return null;
|
|
79
|
+
const hist = (rec && rec.lcHistory) || [];
|
|
80
|
+
return el('div', { 'data-r18': 'lifecycle-notes', style: 'display:flex;flex-direction:column;gap:2px;padding:4px 14px 6px;border-bottom:1px solid var(--border)' },
|
|
81
|
+
el('span', { 'data-r18': 'lifecycle-explain', style: 'font-size:10.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
82
|
+
'Hold, close and cancel take this initiative off the board and stop it gating; reopen puts it back. They are the only statuses you set by hand. Every other status is read from git and the ledgers, so it cannot be edited here.'),
|
|
83
|
+
hist.length ? el('div', { 'data-r18': 'lifecycle-history', style: 'display:flex;flex-direction:column;gap:1px' },
|
|
84
|
+
el('span', { style: LAB + ';padding-top:2px' }, 'lifecycle history · ' + hist.length),
|
|
85
|
+
hist.map((h) => el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
86
|
+
iv(String(h.at || '').slice(0, 10) + ' · ' + h.state + (h.reason ? ' — ' + h.reason : ''))))) : null);
|
|
87
|
+
}
|
|
@@ -60,7 +60,7 @@ const wsId = () => state.wsId || state.ws || state.product || '';
|
|
|
60
60
|
* `ws`, and is read from and saved to THAT tree. Two trees can hold the same path with different
|
|
61
61
|
* content, so every per-document cache keys on tree + path; a main-tree tab keys on path alone,
|
|
62
62
|
* exactly as before. */
|
|
63
|
-
const docKey = (tab) => (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab ? tab.path : '');
|
|
63
|
+
const docKey = (tab) => (tab && tab.guide ? 'guide\u241f' : '') + (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab && tab.ref ? tab.ref + '\u241f' : '') + (tab ? tab.path : '');
|
|
64
64
|
const wsOf = (tab) => (tab && tab.ws) || wsId();
|
|
65
65
|
|
|
66
66
|
/* A REFERENCE is not a file. Panel 3's links section files `jira://ATL-501`, `confluence://PM/x`,
|
|
@@ -97,9 +97,12 @@ function requestDoc(tab) {
|
|
|
97
97
|
if (pending.has(path)) return;
|
|
98
98
|
pending.add(path);
|
|
99
99
|
const done = (text) => { pending.delete(path); disk.set(path, text); setState({}); };
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
// A guide page ships with the app and is read from it, not from the repository (status-clarity R6).
|
|
101
|
+
const load = tab.guide
|
|
102
|
+
? fetch('/api/guide?page=' + encodeURIComponent(tab.path)).then((r) => (r.ok ? r.text() : Promise.reject(new Error('no such guide page'))))
|
|
103
|
+
: (dataMod && typeof dataMod.readDoc === 'function')
|
|
104
|
+
? dataMod.readDoc(tab.path, tab.ws, tab.ref)
|
|
105
|
+
: fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path) + (tab.ref ? '&ref=' + encodeURIComponent(tab.ref) : ''))
|
|
103
106
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))));
|
|
104
107
|
Promise.resolve(load).then(
|
|
105
108
|
(text) => { unread.delete(path); done(typeof text === 'string' ? text : String(text == null ? '' : text)); },
|
|
@@ -478,11 +481,13 @@ export function mountDoc(host, tab) {
|
|
|
478
481
|
if (!FIXTURE && extOfPath(tab.path) === 'xlsx') { setChildren(host, [sheetBody(tab)]); return; }
|
|
479
482
|
const raw = docText(tab);
|
|
480
483
|
const text = state.docEdits[docKey(tab)] ?? raw;
|
|
481
|
-
const dm = state.docModes[tab.id] ?? 'view';
|
|
484
|
+
const dm = (tab.readonly && (state.docModes[tab.id] === 'edit' || state.docModes[tab.id] === 'diff')) ? 'view' : (state.docModes[tab.id] ?? 'view');
|
|
482
485
|
const dirty = text !== raw;
|
|
483
486
|
const err = saveErr.get(docKey(tab));
|
|
484
487
|
|
|
485
|
-
|
|
488
|
+
// A file opened from the initiative's git files is evidence of what changed: it is read, never edited or saved here.
|
|
489
|
+
const readonly = !!tab.readonly;
|
|
490
|
+
const modeBtns = ['view', 'raw', 'edit', 'diff'].filter((mm) => !(readonly && (mm === 'edit' || mm === 'diff'))).map((mm) => el('button', {
|
|
486
491
|
onClick: () => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: mm } })),
|
|
487
492
|
style: 'font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:'
|
|
488
493
|
+ (dm === mm ? 'var(--secondary)' : 'transparent')
|