create-pmos 0.36.0 → 0.38.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/bin/create-pmos.js +22 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +6 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +371 -0
- package/workspace/public/pm/modes.js +7 -4
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +690 -40
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* memory.js — the workspace's door to curated repository memory (D72).
|
|
3
|
+
*
|
|
4
|
+
* The ledger code is ONE file, scripts/pmos-memory.js, shared with the CLI agents run. It is loaded the
|
|
5
|
+
* way the workflow engine is: from the checkout two directories up, or from the copy the packaging step
|
|
6
|
+
* vendors into lib/engine/. No server code appends to memory.jsonl except through it.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const PATHS = [
|
|
12
|
+
path.join(__dirname, '..', '..', 'scripts', 'pmos-memory.js'),
|
|
13
|
+
path.join(__dirname, 'engine', 'pmos-memory.js'),
|
|
14
|
+
];
|
|
15
|
+
function core() {
|
|
16
|
+
for (const p of PATHS) {
|
|
17
|
+
if (!fs.existsSync(p)) continue;
|
|
18
|
+
try { return { ok: true, core: require(p) }; }
|
|
19
|
+
catch (e) { return { ok: false, error: `the memory tool at ${p} failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
|
|
20
|
+
}
|
|
21
|
+
return { ok: false, error: 'the memory tool is not present in this install' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MARK = '<!-- pmos:memory -->';
|
|
25
|
+
const END = '<!-- /pmos:memory -->';
|
|
26
|
+
function rel(root, p) { return path.relative(root, p).split(path.sep).join('/'); }
|
|
27
|
+
function toolPath(root) { return fs.existsSync(path.join(root, 'pmos', 'scripts', 'pmos-memory.js')) ? 'pmos/scripts/pmos-memory.js' : 'scripts/pmos-memory.js'; }
|
|
28
|
+
function wired(root) {
|
|
29
|
+
try { return fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8').includes(MARK); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
/* Wire the repository's CLAUDE.md to import the two snapshot views. Idempotent; appends one marked
|
|
32
|
+
* block and never touches anything else in the file. Only ever called on the PM's press. */
|
|
33
|
+
function wire(root) {
|
|
34
|
+
const c = core(); if (!c.ok) return c;
|
|
35
|
+
const dir = c.core.memDir(root, true);
|
|
36
|
+
c.core.render(root);
|
|
37
|
+
if (wired(root)) return { ok: true, changed: false };
|
|
38
|
+
const file = path.join(root, 'CLAUDE.md');
|
|
39
|
+
let cur = ''; try { cur = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
|
|
40
|
+
const block = [MARK, `@${rel(root, path.join(dir, 'MEMORY.md'))}`, `@${rel(root, path.join(dir, 'USER.md'))}`, '',
|
|
41
|
+
'The two files above are this repository\'s curated memory, read once at session start (D72). Save a new durable fact with:',
|
|
42
|
+
`\`node ${toolPath(root)} add --text "…" --source "kind:ref"\` (add \`--target user\` for a preference of the PM). Do not edit the files by hand: they are generated.`,
|
|
43
|
+
'An entry is unconfirmed until the PM confirms it; say so when you rely on one.', END, ''].join('\n');
|
|
44
|
+
fs.writeFileSync(file, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}${cur ? '\n' : ''}${block}`);
|
|
45
|
+
return { ok: true, changed: true };
|
|
46
|
+
}
|
|
47
|
+
/* Rebuild the views for a session about to start. Local file writes only. */
|
|
48
|
+
function renderFor(root) {
|
|
49
|
+
const c = core(); if (!c.ok) return;
|
|
50
|
+
const dir = c.core.memDir(root, false);
|
|
51
|
+
if (dir && fs.existsSync(path.join(dir, 'memory.jsonl'))) c.core.render(root);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { core, wire, wired, renderFor, MARK, END };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* models.js — the workspace's door to model routing (D75).
|
|
3
|
+
*
|
|
4
|
+
* The routing code is ONE file, scripts/pmos-model.js, shared with the CLI an agent runs. It is loaded the
|
|
5
|
+
* way the workflow engine and the memory tool are: from the checkout two directories up, or from the copy the
|
|
6
|
+
* packaging step vendors into lib/engine/.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const PATHS = [
|
|
12
|
+
path.join(__dirname, '..', '..', 'scripts', 'pmos-model.js'),
|
|
13
|
+
path.join(__dirname, 'engine', 'pmos-model.js'),
|
|
14
|
+
];
|
|
15
|
+
function core() {
|
|
16
|
+
for (const p of PATHS) {
|
|
17
|
+
if (!fs.existsSync(p)) continue;
|
|
18
|
+
try { return { ok: true, core: require(p) }; }
|
|
19
|
+
catch (e) { return { ok: false, error: `the model router at ${p} failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
|
|
20
|
+
}
|
|
21
|
+
return { ok: false, error: 'the model router is not present in this install' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Resolve what to launch for a session. Never throws. `ask` = { explicit:{tier,model,effort}, scheduleTier,
|
|
25
|
+
* skillTier, skill, text }. Returns { ok, state, tier, model, effort, decidedBy, why, args, error? } where state is
|
|
26
|
+
* 'not-asked' | 'applied' | 'no-model-control'. */
|
|
27
|
+
function resolveForSpawn(root, agent, ask) {
|
|
28
|
+
const a = ask || {};
|
|
29
|
+
const nothingAsked = !(a.explicit && (a.explicit.tier || a.explicit.model)) && !a.scheduleTier && !a.skillTier && !a.skill && !a.text;
|
|
30
|
+
const c = core();
|
|
31
|
+
if (!c.ok) return nothingAsked ? { ok: true, state: 'not-asked', tier: null, args: [] } : { ok: false, error: c.error };
|
|
32
|
+
const clean = (v) => (v == null || v === '' ? undefined : String(v));
|
|
33
|
+
const explicit = { tier: clean(a.explicit && a.explicit.tier), model: clean(a.explicit && a.explicit.model), effort: clean(a.explicit && a.explicit.effort) };
|
|
34
|
+
const r = c.core.resolve(root, { skill: a.skill, text: a.text, scheduleTier: clean(a.scheduleTier), skillTier: clean(a.skillTier), explicit, agent });
|
|
35
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
36
|
+
return r;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* The sentence a pane or a list row shows. Three states kept apart, and a session recorded before routing existed
|
|
40
|
+
* says "not recorded" — never "not asked". */
|
|
41
|
+
function line(model) {
|
|
42
|
+
if (!model) return 'model: not recorded (this session started before model routing existed)';
|
|
43
|
+
if (model.state === 'refused') return `model: choice refused — ${model.why}`;
|
|
44
|
+
if (model.switched) return `model: ${model.switched.model} — switched by the PM at ${String(model.switched.at).slice(11, 16)}; effort unchanged`;
|
|
45
|
+
if (model.state === 'applied') return `model: ${model.model}${model.effort ? ` · effort ${model.effort}` : ''} (${model.tier}, ${model.decidedBy})`;
|
|
46
|
+
if (model.state === 'no-model-control') return 'model: asked for, but this binary has no model control in PMOS — it runs its own default';
|
|
47
|
+
return 'model: not asked — the harness default';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { core, resolveForSpawn, line };
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
// an image extension wins over its text twin (.svg) only when there is a real extension;
|
|
69
69
|
// opening an .svg as a DOC still shows source (TEXT_EXT), inline ![]() treats it as an image.
|
|
70
70
|
if (Object.prototype.hasOwnProperty.call(IMAGE_MIME, ext)) return 'image';
|
|
71
|
+
// overhaul-r2 D1 — a PDF opens in the browser's own viewer through /api/asset (never as text)
|
|
72
|
+
if (ext === 'pdf') return 'pdf';
|
|
73
|
+
// overhaul-r3 P10 — a workbook is read in the browser (public/pm/xlsx-read.js), never as text
|
|
74
|
+
if (ext === 'xlsx') return 'sheet';
|
|
71
75
|
if (TEXT_EXT.has(ext)) return 'text';
|
|
72
76
|
if (!ext && TEXT_BASENAMES.has(base)) return 'text';
|
|
73
77
|
return 'inert';
|
|
@@ -78,6 +82,13 @@
|
|
|
78
82
|
return IMAGE_MIME[extOf(name)] || null;
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
/** MIME for /api/asset: an allowlisted image, or a PDF (overhaul-r2 D1); null otherwise. */
|
|
86
|
+
function assetMime(name) {
|
|
87
|
+
if (extOf(name) === 'pdf') return 'application/pdf';
|
|
88
|
+
if (extOf(name) === 'xlsx') return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
89
|
+
return imageMime(name);
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
/** a markdown-body kind served as escaped source (vs. rendered markdown). */
|
|
82
93
|
function isTextSource(kind) { return kind === 'text'; }
|
|
83
94
|
|
|
@@ -93,6 +104,6 @@
|
|
|
93
104
|
return true; // a plain relative / root-relative repo path
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
return { previewKind, imageMime, isTextSource, isRepoRelativeSrc, extOf,
|
|
107
|
+
return { previewKind, imageMime, assetMime, isTextSource, isRepoRelativeSrc, extOf,
|
|
97
108
|
MARKDOWN_EXT, TEXT_EXT, HTML_EXT, IMAGE_MIME };
|
|
98
109
|
});
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // JS getDay() index
|
|
14
|
-
const CADENCES = ['daily', 'weekly', 'bi-weekly', 'monthly'];
|
|
14
|
+
const CADENCES = ['daily', 'weekly', 'bi-weekly', 'monthly', 'once']; // R11: `once` — one date and time, then it stays off
|
|
15
|
+
const ONCE_GRACE_MS = 15 * 60 * 1000; // a one-off whose moment passed while nothing was running is MISSED, not caught up
|
|
15
16
|
|
|
16
17
|
function parseTime(t) {
|
|
17
18
|
const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '09:00'));
|
|
@@ -27,8 +28,15 @@ function atTime(d, time) {
|
|
|
27
28
|
function dayIndex(day) { const i = DAYS.indexOf(String(day)); return i < 0 ? 1 : i; } // default Mon
|
|
28
29
|
|
|
29
30
|
// The most recent scheduled slot at or before `now` (Date), or null if none exists yet.
|
|
31
|
+
function onceSlot(schedule) {
|
|
32
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(schedule.date || ''));
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const { h, mi } = (() => { const t = parseTime(schedule.time); return { h: t.h, mi: t.m }; })();
|
|
35
|
+
return new Date(+m[1], +m[2] - 1, +m[3], h, mi, 0, 0);
|
|
36
|
+
}
|
|
30
37
|
function lastSlot(schedule, now) {
|
|
31
38
|
const cad = schedule.cadence;
|
|
39
|
+
if (cad === 'once') { const o = onceSlot(schedule); return o && o <= now ? o : null; }
|
|
32
40
|
if (cad === 'daily') {
|
|
33
41
|
const today = atTime(now, schedule.time);
|
|
34
42
|
return today <= now ? today : addDays(today, -1);
|
|
@@ -54,6 +62,7 @@ function lastSlot(schedule, now) {
|
|
|
54
62
|
// The next scheduled slot strictly after `now`.
|
|
55
63
|
function nextSlot(schedule, now) {
|
|
56
64
|
const cad = schedule.cadence;
|
|
65
|
+
if (cad === 'once') { const o = onceSlot(schedule); return o && o > now ? o : null; }
|
|
57
66
|
if (cad === 'daily') {
|
|
58
67
|
const today = atTime(now, schedule.time);
|
|
59
68
|
return today > now ? today : addDays(today, 1);
|
|
@@ -85,11 +94,19 @@ function isDue(schedule, now = new Date()) {
|
|
|
85
94
|
if (!slot) return false;
|
|
86
95
|
const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
|
|
87
96
|
if (last && last >= slot) return false;
|
|
97
|
+
// A one-off fires inside its grace window, once; after that it is missed (see isMissed), never caught up.
|
|
98
|
+
if (schedule.cadence === 'once') return !last && (now - slot) <= ONCE_GRACE_MS;
|
|
88
99
|
if (schedule.cadence === 'bi-weekly' && last) {
|
|
89
100
|
if ((now - last) / 86400000 < 13) return false;
|
|
90
101
|
}
|
|
91
102
|
return true;
|
|
92
103
|
}
|
|
104
|
+
// A one-off whose moment passed beyond the grace window without firing. The tick marks it off and says so.
|
|
105
|
+
function isMissed(schedule, now = new Date()) {
|
|
106
|
+
if (!schedule || schedule.cadence !== 'once' || !schedule.enabled || schedule.lastRun) return false;
|
|
107
|
+
const o = onceSlot(schedule);
|
|
108
|
+
return !!o && (now - o) > ONCE_GRACE_MS;
|
|
109
|
+
}
|
|
93
110
|
// The next fire time as an ISO string (or null).
|
|
94
111
|
function nextRun(schedule, now = new Date()) {
|
|
95
112
|
const s = nextSlot(schedule, now);
|
|
@@ -103,6 +120,7 @@ function humanCadence(schedule) {
|
|
|
103
120
|
case 'weekly': return `every ${schedule.day || 'Mon'} at ${t}`;
|
|
104
121
|
case 'bi-weekly': return `every other ${schedule.day || 'Mon'} at ${t}`;
|
|
105
122
|
case 'monthly': return `monthly on day ${schedule.dayOfMonth || 1} at ${t}`;
|
|
123
|
+
case 'once': return `once, ${schedule.date || '?'} at ${t}`;
|
|
106
124
|
default: return schedule.cadence || '';
|
|
107
125
|
}
|
|
108
126
|
}
|
|
@@ -112,6 +130,12 @@ function normalizeSchedule(input) {
|
|
|
112
130
|
if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
|
|
113
131
|
if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
|
|
114
132
|
if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
|
|
133
|
+
if (s.cadence === 'once') {
|
|
134
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.date || ''))) return { ok: false, error: 'a one-off needs a date (YYYY-MM-DD)' };
|
|
135
|
+
const slot = onceSlot({ date: s.date, time: s.time });
|
|
136
|
+
if (!slot || Number.isNaN(slot.getTime())) return { ok: false, error: 'that date is not a real date' };
|
|
137
|
+
if (slot <= new Date()) return { ok: false, error: 'a one-off must be in the future — a past moment would never fire' };
|
|
138
|
+
}
|
|
115
139
|
return {
|
|
116
140
|
ok: true,
|
|
117
141
|
schedule: {
|
|
@@ -119,6 +143,11 @@ function normalizeSchedule(input) {
|
|
|
119
143
|
cadence: s.cadence,
|
|
120
144
|
day: s.day || 'Mon',
|
|
121
145
|
dayOfMonth: Math.min(28, Math.max(1, +s.dayOfMonth || 1)),
|
|
146
|
+
...(s.cadence === 'once' ? { date: String(s.date) } : {}),
|
|
147
|
+
// R11 — the PM's request for this run, added to the skill prompt so an unattended run need not ask.
|
|
148
|
+
...(s.args ? { args: String(s.args).trim().slice(0, 2000) } : {}),
|
|
149
|
+
// D75 — the PM's tier for this schedule (fast | balanced | deep); anything else is dropped, never guessed.
|
|
150
|
+
...(/^(fast|balanced|deep)$/.test(String(s.tier || '')) ? { tier: String(s.tier) } : {}),
|
|
122
151
|
time: parseTimeStr(s.time),
|
|
123
152
|
tz: s.tz || 'local',
|
|
124
153
|
task: s.task || s.skill,
|
|
@@ -129,4 +158,4 @@ function normalizeSchedule(input) {
|
|
|
129
158
|
}
|
|
130
159
|
function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
|
|
131
160
|
|
|
132
|
-
module.exports = { CADENCES, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
|
|
161
|
+
module.exports = { CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
|
|
@@ -67,6 +67,8 @@ function parseSkill(text) {
|
|
|
67
67
|
run,
|
|
68
68
|
surface: fm.surface || null,
|
|
69
69
|
agent: fm.agent || 'claude',
|
|
70
|
+
// D75 — the skill's own preferred tier; anything but a known tier is ignored here and the policy's route decides.
|
|
71
|
+
tier: /^(fast|balanced|deep)$/.test(fm.tier || '') ? fm.tier : null,
|
|
70
72
|
output: (lists.output || []).filter(Boolean).slice(0, MAX_OUTPUT),
|
|
71
73
|
body,
|
|
72
74
|
};
|
|
@@ -92,7 +94,9 @@ function loadDir(dir, source) {
|
|
|
92
94
|
// built-ins last, so a built-in's load-bearing fast-path can never be shadowed by a user file.
|
|
93
95
|
function loadSkills(dirs) {
|
|
94
96
|
const byId = new Map();
|
|
95
|
-
|
|
97
|
+
// An entry is a directory to read, or `skills` already loaded and vetted by the caller (D76: discovered skills,
|
|
98
|
+
// which only the registry's approved-and-matching rows may supply).
|
|
99
|
+
for (const e of dirs) for (const s of (e.skills || loadDir(e.dir, e.source))) byId.set(s.id, s);
|
|
96
100
|
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* skills-registry.js — the workspace's door to discovered and generated skills (D76).
|
|
3
|
+
*
|
|
4
|
+
* The registry code is ONE file, scripts/pmos-skills.js (shared with the CLI an agent runs): checkout two
|
|
5
|
+
* directories up, or the copy the packaging step vendors into lib/engine/.
|
|
6
|
+
*
|
|
7
|
+
* APPROVAL LIVES HERE, NOT IN THE REPOSITORY. A skill is approved by a PM press, and the record of it is a line in
|
|
8
|
+
* the workspace's runtime state on THIS machine (`skill-approvals.jsonl`), keyed by the repository's real path. The
|
|
9
|
+
* repository's registry can say what exists and what was rejected, but it can never make anything run — so a commit,
|
|
10
|
+
* a teammate's push or an agent's hand-edit cannot approve a skill on the PM's behalf. The cost is honest: a skill
|
|
11
|
+
* approved on one machine is quarantined on another until the PM approves it there, after reading it there.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const PATHS = [
|
|
18
|
+
path.join(__dirname, '..', '..', 'scripts', 'pmos-skills.js'),
|
|
19
|
+
path.join(__dirname, 'engine', 'pmos-skills.js'),
|
|
20
|
+
];
|
|
21
|
+
function core() {
|
|
22
|
+
for (const p of PATHS) {
|
|
23
|
+
if (!fs.existsSync(p)) continue;
|
|
24
|
+
try { return { ok: true, core: require(p) }; }
|
|
25
|
+
catch (e) { return { ok: false, error: `the skills registry at ${p} failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
|
|
26
|
+
}
|
|
27
|
+
return { ok: false, error: 'the skills registry is not present in this install' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rootKey = (root) => {
|
|
31
|
+
let real = root; try { real = fs.realpathSync(root); } catch { /* use as given */ }
|
|
32
|
+
return crypto.createHash('sha256').update(real).digest('hex').slice(0, 16);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readApprovals(file) {
|
|
36
|
+
let text = ''; try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const line of text.split('\n')) { if (!line.trim()) continue; try { const e = JSON.parse(line); if (e && e.op && e.id && e.sha && e.root) out.push(e); } catch { /* torn */ } }
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function appendApproval(file, ev) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
44
|
+
try {
|
|
45
|
+
const st = fs.fstatSync(fd); if (!st.isFile() || st.nlink !== 1) throw new Error('skill-approvals.jsonl is not a plain single-link file');
|
|
46
|
+
const before = st.size; const buf = Buffer.from(JSON.stringify(ev) + '\n');
|
|
47
|
+
if (fs.writeSync(fd, buf) !== buf.length) { try { fs.ftruncateSync(fd, before); } catch { /* best effort */ } throw new Error('short write — rolled back'); }
|
|
48
|
+
} finally { fs.closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
// The local approvals for ONE repository, as the core's resolve() wants them: [{id, sha, at}]
|
|
51
|
+
function approvedFor(file, root) {
|
|
52
|
+
const k = rootKey(root);
|
|
53
|
+
return readApprovals(file).filter((e) => e.root === k && e.op === 'approve' && e.by === 'human:wawan');
|
|
54
|
+
}
|
|
55
|
+
function readsFor(file, root) {
|
|
56
|
+
const k = rootKey(root); const set = new Set();
|
|
57
|
+
for (const e of readApprovals(file)) if (e.root === k && e.op === 'read' && e.by === 'human:wawan') set.add(`${e.id}@${e.sha}`);
|
|
58
|
+
return set;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The skill objects the loader will offer for a repository: only approved + still-matching candidates. Each is the
|
|
62
|
+
* parsed skill with the harness FORCED — an agent skill running claude, whatever the file's own keys say. */
|
|
63
|
+
function loadApproved(root, approvalsFile, parseSkill) {
|
|
64
|
+
const c = core(); if (!c.ok) return { skills: [], blocked: [], error: c.error };
|
|
65
|
+
const res = c.core.resolve(root, approvedFor(approvalsFile, root));
|
|
66
|
+
const skills = res.skills.map((r) => {
|
|
67
|
+
const s = parseSkill(r.text);
|
|
68
|
+
return { ...s, name: r.id, id: r.id, skill: r.id, label: s.label || r.id, run: 'agent', agent: 'claude', surface: null,
|
|
69
|
+
source: 'discovered', file: r.file, sha12: r.sha12, license: r.row.license, origin: r.row.origin };
|
|
70
|
+
});
|
|
71
|
+
return { skills, blocked: res.blocked };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { core, rootKey, readApprovals, appendApproval, approvedFor, readsFor, loadApproved };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* sync — fetch, bring in the other machine's work, commit THIS machine's records, push (D70).
|
|
3
|
+
*
|
|
4
|
+
* Reached ONLY by the PM's press on `Sync` (POST /api/sync). Nothing else in the workspace calls
|
|
5
|
+
* `fetch`, `rebase`, `commit` or `push`. Every step either proves itself safe or stops and says why;
|
|
6
|
+
* a stop leaves the working tree exactly as it was. Never `--force`, never a merge commit, never a
|
|
7
|
+
* `reset --hard`, never a `clean`, never `stash drop`.
|
|
8
|
+
*
|
|
9
|
+
* What it commits is RECORD PATHS only — the PM's own session wrote them. A PM's other dirty files
|
|
10
|
+
* are left alone and reported. A diverged branch is brought together by rebasing THIS machine's own
|
|
11
|
+
* unpushed commits onto the remote; any conflict aborts the rebase entirely.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execFile } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const RECORD_PATHS = ['planning', 'pmos', '.pmos-profile', '.gitattributes'];
|
|
19
|
+
const NET_TIMEOUT_MS = 60000;
|
|
20
|
+
const LOCAL_TIMEOUT_MS = 10000;
|
|
21
|
+
|
|
22
|
+
function run(root, args, { net = false, raw = false } = {}) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
execFile('git', ['-C', root, ...args], {
|
|
25
|
+
encoding: 'utf8', timeout: net ? NET_TIMEOUT_MS : LOCAL_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024,
|
|
26
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
|
|
27
|
+
}, (err, stdout, stderr) => resolve({ ok: !err, out: raw ? String(stdout || '') : String(stdout || '').trim(), err: String((stderr || (err && err.message)) || '').trim() }));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRecord = (rel) => RECORD_PATHS.some((p) => rel === p || rel.startsWith(`${p}/`));
|
|
32
|
+
|
|
33
|
+
// PM repositories append ledgers from several machines; `merge=union` keeps both sides' lines instead
|
|
34
|
+
// of conflicting (D70). Added once, committed with the records. Never touches a product-code repository.
|
|
35
|
+
const UNION_LINE = '*.jsonl merge=union';
|
|
36
|
+
function ensureUnionAttribute(root) {
|
|
37
|
+
try {
|
|
38
|
+
const prof = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8').split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
|
|
39
|
+
if (prof !== 'pm-artifacts') return false;
|
|
40
|
+
const f = path.join(root, '.gitattributes');
|
|
41
|
+
const cur = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
|
|
42
|
+
if (cur.split('\n').some((l) => l.trim() === UNION_LINE)) return false;
|
|
43
|
+
fs.writeFileSync(f, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}${UNION_LINE}\n`);
|
|
44
|
+
return true;
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Everything here is local — no network. Safe to call on every render.
|
|
49
|
+
async function status(root) {
|
|
50
|
+
const inside = await run(root, ['rev-parse', '--is-inside-work-tree']);
|
|
51
|
+
if (!inside.ok) return { ok: false, error: 'not a git repository' };
|
|
52
|
+
const br = await run(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
53
|
+
const branch = br.ok ? br.out : null;
|
|
54
|
+
if (!branch || branch === 'HEAD') return { ok: true, branch: null, state: 'detached', message: 'a detached HEAD cannot sync' };
|
|
55
|
+
const up = await run(root, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`]);
|
|
56
|
+
const porcelain = await run(root, ['status', '--porcelain', '--untracked-files=all'], { raw: true });
|
|
57
|
+
const files = porcelain.out ? porcelain.out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/.* -> /, '')) : [];
|
|
58
|
+
const dirtyRecords = files.filter(isRecord);
|
|
59
|
+
const dirtyOther = files.filter((f) => !isRecord(f));
|
|
60
|
+
let ahead = null; let behind = null;
|
|
61
|
+
if (up.ok) {
|
|
62
|
+
const c = await run(root, ['rev-list', '--left-right', '--count', `HEAD...${up.out}`]);
|
|
63
|
+
if (c.ok) { const [a, b] = c.out.split(/\s+/).map(Number); ahead = a; behind = b; }
|
|
64
|
+
}
|
|
65
|
+
const mid = ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD']
|
|
66
|
+
.some((f) => fs.existsSync(path.join(root, '.git', f)));
|
|
67
|
+
const state = mid ? 'mid-operation' : !up.ok ? 'no-upstream'
|
|
68
|
+
: ahead && behind ? 'diverged' : behind ? 'behind' : ahead ? 'ahead'
|
|
69
|
+
: dirtyRecords.length ? 'dirty' : 'in-sync';
|
|
70
|
+
return { ok: true, branch, upstream: up.ok ? up.out : null, ahead, behind,
|
|
71
|
+
dirty_records: dirtyRecords.length, dirty_other: dirtyOther.length, state,
|
|
72
|
+
network_used: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The press. Returns { ok, steps:[{step, ok, note}], stopped_at?, message }.
|
|
76
|
+
async function sync(root, { label } = {}) {
|
|
77
|
+
const steps = [];
|
|
78
|
+
const stop = (step, message) => { steps.push({ step, ok: false, note: message }); return { ok: false, steps, stopped_at: step, message }; };
|
|
79
|
+
const done = (step, note) => steps.push({ step, ok: true, note });
|
|
80
|
+
|
|
81
|
+
const s0 = await status(root);
|
|
82
|
+
if (!s0.ok) return stop('check', s0.error);
|
|
83
|
+
if (s0.state === 'detached') return stop('check', s0.message);
|
|
84
|
+
if (s0.state === 'mid-operation') return stop('check', 'a merge, rebase or cherry-pick is already in progress here; finish or abort it, then sync');
|
|
85
|
+
if (s0.state === 'no-upstream') return stop('check', `branch '${s0.branch}' has no upstream; set one once with: git push -u origin ${s0.branch}`);
|
|
86
|
+
done('check', `${s0.branch} → ${s0.upstream}`);
|
|
87
|
+
|
|
88
|
+
const f = await run(root, ['fetch', '--prune', s0.upstream.split('/')[0]], { net: true });
|
|
89
|
+
if (!f.ok) return stop('fetch', `fetch failed — ${f.err || 'no output'}`);
|
|
90
|
+
done('fetch', 'fetched');
|
|
91
|
+
|
|
92
|
+
// Commit THIS machine's records first, so nothing the PM wrote can be lost by what follows.
|
|
93
|
+
const addedAttr = ensureUnionAttribute(root);
|
|
94
|
+
const s1 = await status(root);
|
|
95
|
+
if (s1.dirty_records || addedAttr) {
|
|
96
|
+
const present = RECORD_PATHS.filter((p) => fs.existsSync(path.join(root, p)));
|
|
97
|
+
const add = await run(root, ['add', '--', ...present]);
|
|
98
|
+
if (!add.ok) return stop('commit', `could not stage records — ${add.err}`);
|
|
99
|
+
const staged = await run(root, ['diff', '--cached', '--name-only']);
|
|
100
|
+
if (staged.out) {
|
|
101
|
+
const msg = `chore(pmos): sync records from ${label || os.hostname()}`;
|
|
102
|
+
const c = await run(root, ['commit', '-m', msg, '--no-verify']);
|
|
103
|
+
if (!c.ok) return stop('commit', /identity unknown|empty ident/i.test(c.err)
|
|
104
|
+
? 'git has no author name or email on this machine, so the records cannot be committed — set them once: git config --global user.name "Your Name" && git config --global user.email you@example.com'
|
|
105
|
+
: `could not commit records — ${c.err}`);
|
|
106
|
+
done('commit', `${staged.out.split('\n').length} record file(s)`);
|
|
107
|
+
}
|
|
108
|
+
} else done('commit', 'no uncommitted records');
|
|
109
|
+
|
|
110
|
+
const s2 = await status(root);
|
|
111
|
+
if (s2.behind) {
|
|
112
|
+
// Fast-forward when we have nothing of our own; otherwise replay OUR unpushed commits on top.
|
|
113
|
+
const verb = s2.ahead ? ['rebase', '--autostash', s2.upstream] : ['merge', '--ff-only', s2.upstream];
|
|
114
|
+
const r = await run(root, verb);
|
|
115
|
+
if (!r.ok) {
|
|
116
|
+
if (s2.ahead) await run(root, ['rebase', '--abort']);
|
|
117
|
+
return stop('integrate', `${s2.ahead ? 'the two machines changed the same lines — rebase aborted, nothing changed' : 'cannot fast-forward'} — ${r.err.split('\n')[0] || 'see git'}`);
|
|
118
|
+
}
|
|
119
|
+
done('integrate', s2.ahead ? `replayed ${s2.ahead} local commit(s) on the remote` : `fast-forwarded ${s2.behind} commit(s)`);
|
|
120
|
+
} else done('integrate', 'nothing to bring in');
|
|
121
|
+
|
|
122
|
+
const s3 = await status(root);
|
|
123
|
+
if (s3.ahead) {
|
|
124
|
+
const p = await run(root, ['push', s3.upstream.split('/')[0], `HEAD:${s3.upstream.split('/').slice(1).join('/')}`], { net: true });
|
|
125
|
+
if (!p.ok) return stop('push', `push failed — ${p.err.split('\n')[0] || 'no output'}`);
|
|
126
|
+
done('push', `pushed ${s3.ahead} commit(s)`);
|
|
127
|
+
} else done('push', 'nothing to push');
|
|
128
|
+
|
|
129
|
+
return { ok: true, steps, message: 'in sync', after: await status(root) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { status, sync, ensureUnionAttribute, RECORD_PATHS, isRecord };
|
|
@@ -187,6 +187,8 @@ function describeTenant(row, writerId) {
|
|
|
187
187
|
shown,
|
|
188
188
|
label: s.label || shown,
|
|
189
189
|
doing: s.doing || s.lastWords || null,
|
|
190
|
+
// D75 — the sentence that says which model this session was started with (or that it was not asked / has no model control).
|
|
191
|
+
modelLine: s.model_line || null,
|
|
190
192
|
branch: s.branch || null,
|
|
191
193
|
writer: !!(writerId && s.id === writerId),
|
|
192
194
|
risk: endRisk(shown, s.quietMs),
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.37.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
@@ -33,6 +33,7 @@ import { isMobile, mountMobile, applyMobile } from './mobile.js';
|
|
|
33
33
|
const MODE_TITLE = {
|
|
34
34
|
product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
|
|
35
35
|
knowledge: 'knowledge', agent: 'agents', memory: 'memory', connect: 'connections', focus: 'focus',
|
|
36
|
+
portfolio: 'portfolio',
|
|
36
37
|
};
|
|
37
38
|
const DETAIL_TITLE = {
|
|
38
39
|
init: 'initiative', flow: 'workflow', sched: 'scheduled task', skill: 'skill', know: 'decision',
|
|
@@ -163,6 +164,16 @@ const rail0 = makeRail('col0');
|
|
|
163
164
|
const rail1 = makeRail('col1');
|
|
164
165
|
const rail2 = makeRail('col2');
|
|
165
166
|
rail0.label.textContent = 'repositories ⟩';
|
|
167
|
+
/* overhaul-r1 R4: in live mode the collapsed repositories rail also carries one glyph per repository
|
|
168
|
+
* (panel-repos.js#railGlyphs), so a repository is selectable without expanding the panel. The
|
|
169
|
+
* toggle keeps its label and stays the rest of the strip. */
|
|
170
|
+
const rail0Host = el('div', {
|
|
171
|
+
role: 'button', tabindex: '0', title: 'show the repositories panel',
|
|
172
|
+
onClick: () => setState((s) => ({ col0: !s.col0 })),
|
|
173
|
+
onKeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setState((s) => ({ col0: !s.col0 })); } },
|
|
174
|
+
style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;min-height:0;overflow-y:auto;cursor:pointer',
|
|
175
|
+
hover: 'background:var(--accent)',
|
|
176
|
+
});
|
|
166
177
|
|
|
167
178
|
/* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
|
|
168
179
|
* than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
|
|
@@ -199,10 +210,29 @@ function paint(mod, name, host) {
|
|
|
199
210
|
|
|
200
211
|
/* ── render ────────────────────────────────────────────────────────────────────────────────── */
|
|
201
212
|
|
|
213
|
+
/* The rail is still ONE control that re-opens the panel, its first child is still the rotated
|
|
214
|
+
* label, and its text is still exactly `repositories ⟩` — the glyphs draw their initials from a data
|
|
215
|
+
* attribute (tokens.css) and name themselves through aria-label, and CSS `order` lifts them above
|
|
216
|
+
* the label. A glyph click stops at the glyph: it selects, it does not expand. */
|
|
217
|
+
function railRepos() {
|
|
218
|
+
const glyphs = mods.repos && typeof mods.repos.railGlyphs === 'function' ? mods.repos.railGlyphs() : [];
|
|
219
|
+
if (!glyphs.length) {
|
|
220
|
+
if (rail0.label.parentNode !== rail0.btn) rail0.btn.appendChild(rail0.label);
|
|
221
|
+
return rail0.btn;
|
|
222
|
+
}
|
|
223
|
+
rail0.label.style.order = '2';
|
|
224
|
+
rail0.label.style.paddingTop = '10px';
|
|
225
|
+
setChildren(rail0Host, [rail0.label, el('div', { style: 'order:1;padding-top:8px;flex:none' }, glyphs)]);
|
|
226
|
+
return rail0Host;
|
|
227
|
+
}
|
|
228
|
+
|
|
202
229
|
function render() {
|
|
203
230
|
root.setAttribute('data-pm-theme', state.theme);
|
|
204
231
|
themeBtn.textContent = state.theme === 'dark' ? 'theme · dark' : 'theme · light';
|
|
205
|
-
|
|
232
|
+
// overhaul-r1 R2.3: the active tab's line of work, when it is not the main tree
|
|
233
|
+
const actTab = (state.tabs || []).find((t) => t.id === state.active);
|
|
234
|
+
const br = actTab && mods.tabs && typeof mods.tabs.tabBranch === 'function' ? mods.tabs.tabBranch(actTab, true) : null;
|
|
235
|
+
statusLeft.textContent = 'local · persistent server · ' + runningCount() + ' sessions running' + (br ? ' · ⎇ ' + br : '');
|
|
206
236
|
|
|
207
237
|
// Panel 1's rail is a constant; panels 2 and 3 name whatever is behind them, so a PM who folded
|
|
208
238
|
// a panel away can still see what they folded.
|
|
@@ -218,7 +248,7 @@ function render() {
|
|
|
218
248
|
// the document, and panel 4's xterm does not survive that.
|
|
219
249
|
const phone = isMobile();
|
|
220
250
|
setChildren(bodyGrid, phone ? [hostRepos, hostInits, hostDetail, hostTabs] : [
|
|
221
|
-
state.col0 ? hostRepos :
|
|
251
|
+
state.col0 ? hostRepos : railRepos(),
|
|
222
252
|
state.col1 ? hostInits : rail1.btn,
|
|
223
253
|
state.col2 ? hostDetail : rail2.btn,
|
|
224
254
|
hostTabs,
|