create-pmos 0.145.0 → 0.147.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-sessions-panel-test.js +126 -0
- package/workspace/guide/briefing-cards.md +2 -2
- package/workspace/guide/briefing.md +19 -1
- package/workspace/guide/console-calendar.md +18 -3
- package/workspace/guide/console3-sessions.md +35 -0
- package/workspace/guide/data-freshness.md +7 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/briefing-real.js +264 -0
- package/workspace/lib/calendar-layers.js +5 -2
- package/workspace/lib/freshness.js +4 -1
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/routes-briefing.js +25 -5
- package/workspace/lib/routes-calendar-views.js +24 -2
- package/workspace/lib/routes-runs.js +12 -4
- package/workspace/lib/tracker-cli.js +68 -0
- package/workspace/lib/tracker-read.js +40 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-model.js +12 -1
- package/workspace/public/pm/c3-brief-needs.js +16 -4
- package/workspace/public/pm/c3-brief-pulse.js +35 -7
- package/workspace/public/pm/c3-calendar.js +162 -25
- package/workspace/public/pm/c3-case.js +7 -2
- package/workspace/public/pm/c3-demo-cal4.js +3 -3
- package/workspace/public/pm/c3-dock.js +2 -1
- package/workspace/public/pm/c3-session-term.js +44 -0
- package/workspace/public/pm/c3-sheet.js +3 -2
- package/workspace/public/pm/c3-shell.js +2 -1
- package/workspace/public/pm/c3-terminal-data.js +1 -1
- package/workspace/public/pm/c3-terminal-icons.js +1 -1
- package/workspace/public/pm/console3.css +24 -0
- package/workspace/public/pm/run-card.js +4 -1
- package/workspace/public/pm/sessions-panel.js +40 -4
- package/workspace/skills/tracker-refresh.skill +2 -1
- package/workspace/test-files.json +6 -0
|
@@ -36,6 +36,9 @@ const SUGGEST_DAYS = 7;
|
|
|
36
36
|
const WINDOW_MS = 48 * 3600000;
|
|
37
37
|
const PER_KIND = { decision: 6, action: 6, moved: 8, prepared: 8, todo: 8 };
|
|
38
38
|
const LIGHT_VIEWS = ['quarter', 'year'];
|
|
39
|
+
/* a report cadence that recurs more often than monthly is routine: the Calendar counts it instead of naming it (calendar-highlights) */
|
|
40
|
+
const ROUTINE_CADENCE = /\b(daily|weekly|bi-?weekly|fortnightly|stand-?up)\b/i;
|
|
41
|
+
const routineCadence = (c) => ROUTINE_CADENCE.test(String(c || ''));
|
|
39
42
|
|
|
40
43
|
const pad = (n) => String(n).padStart(2, '0');
|
|
41
44
|
const isDay = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
|
|
@@ -130,7 +133,7 @@ function layersFrom(dated, range, today) {
|
|
|
130
133
|
const date = r.issued && isDay(r.issued.at) ? day10(r.issued.at) : day10(r.slot);
|
|
131
134
|
n++;
|
|
132
135
|
put('reports', { id: `report:${r.cadence}:${day10(r.slot)}`, date, kind: 'report', layer: 'reports', title: `${r.cadence} report${r.issued ? '' : ' (not issued)'}`,
|
|
133
|
-
tone: r.issued ? 'ok' : null, ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
|
|
136
|
+
tone: r.issued ? 'ok' : null, cadence: r.cadence, routine: routineCadence(r.cadence), ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
|
|
134
137
|
}
|
|
135
138
|
for (const s of d.reportSchedules || []) {
|
|
136
139
|
if (!s || !s.id) continue;
|
|
@@ -138,7 +141,7 @@ function layersFrom(dated, range, today) {
|
|
|
138
141
|
if (!isDay(dt)) continue;
|
|
139
142
|
n++;
|
|
140
143
|
put('reports', { id: `report-sched:${s.id}:${day10(dt)}`, date: day10(dt), kind: 'report', layer: 'reports', title: `${str(s.name) || s.cadence || s.id} (scheduled)`, tone: null,
|
|
141
|
-
ref: { type: 'report', id: s.cadence || s.id } });
|
|
144
|
+
...(s.cadence ? { cadence: s.cadence } : {}), routine: routineCadence(s.cadence) || routineCadence(s.name), ref: { type: 'report', id: s.cadence || s.id } });
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
if (!n) why('reports', 'no report file and no report schedule gives an issue date');
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
const fs = require('fs');
|
|
27
27
|
const path = require('path');
|
|
28
28
|
const repoWatch = require('./repo-watch.js');
|
|
29
|
+
const trackerRead = require('./tracker-read.js');
|
|
29
30
|
|
|
30
31
|
const H = 3600 * 1000;
|
|
31
32
|
const SOURCES = Object.freeze([
|
|
@@ -153,8 +154,10 @@ function create(o) {
|
|
|
153
154
|
out.push(row(S.jira, jiraAsOf(w.repoStateDir)));
|
|
154
155
|
const mw = o.stateDir ? readJson(path.join(o.stateDir, 'my-work', 'read.json'), null) : null;
|
|
155
156
|
out.push(row(S['my-work'], mw && mw.read_at ? Date.parse(mw.read_at) || 0 : 0));
|
|
157
|
+
// the read the skill stamped when it finished (a press of refresh or the schedule), else the schedule's last start
|
|
156
158
|
const tr = scheduleOf('tracker-refresh');
|
|
157
|
-
|
|
159
|
+
const trRead = o.stateDir ? trackerRead.readAtMs(o.stateDir, w.root) : 0;
|
|
160
|
+
out.push(row(S.tracker, Math.max(trRead, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0)));
|
|
158
161
|
return out;
|
|
159
162
|
}
|
|
160
163
|
|
|
@@ -50,6 +50,9 @@ const VERBS = Object.freeze({
|
|
|
50
50
|
// Not a pm-os-absorb verb (run: null): workspace-data-freshness added it through the same table.
|
|
51
51
|
calendar: Object.freeze({ module: 'calendar-cli.js', run: null, guide: 'data-freshness.md',
|
|
52
52
|
help: 'pmos calendar import|status — the calendar events an agent session read, saved for Briefing and Calendar (pmos calendar help)' }),
|
|
53
|
+
// Not a pm-os-absorb verb (run: null): tracker-read-freshness added it through the same table.
|
|
54
|
+
tracker: Object.freeze({ module: 'tracker-cli.js', run: null, guide: 'data-freshness.md',
|
|
55
|
+
help: 'pmos tracker done|status — record when the tracker-refresh skill finished a read of this repository (pmos tracker help)' }),
|
|
53
56
|
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
54
57
|
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
55
58
|
});
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* { ok, scope, lens, zoom, cards, ticker, banner, own_needs }
|
|
5
5
|
* scope: whose deck asks. brief (or none) runs the engine's briefing detectors, rep its reports detectors (console3-deck-flow-fixes).
|
|
6
6
|
* Any other scope answers no cards, so a page never shows another page's cards.
|
|
7
|
+
* brief scope also adds the PM's own data (lib/briefing-real.js, initiative briefing-real-data): cards from today's morning brief
|
|
8
|
+
* (worth asking, and the PM's priorities), from the assigned Jira and WBS items that are overdue, due today or blocked, and from
|
|
9
|
+
* the Work items that are overdue or due today. Overdue cards lead, the engine's cards follow unchanged, the rest come after;
|
|
10
|
+
* at most 5 are added. Each carries `origin` (brief, jira, wbs, work), and `url` when the record has a link.
|
|
11
|
+
* needs: "Needs you" rows (brief scope): overdue, blocked, decision and due today assigned items, the engine's cards that carry a
|
|
12
|
+
* move or are Critical (as { card }), and the brief's worth asking items. The PM's own Work items stay in own_needs.
|
|
7
13
|
* own_needs: the PM's own items overdue or due today, as needs rows (lib/needs-overdue.js), overdue first. Briefing scope only; the page shows them above the cards.
|
|
8
14
|
* notices: the drafts a dashboard schedule made in the last 7 days that are still in the outbox (lib/report-schedules.js noticesOf).
|
|
9
15
|
* Additive; the bell lists them after the cards, and each opens Reports > Drafts.
|
|
@@ -28,7 +34,8 @@ function lensFor(date) {
|
|
|
28
34
|
|
|
29
35
|
function tickerOf(cards) {
|
|
30
36
|
return cards.slice(0, 3).map((c) => {
|
|
31
|
-
|
|
37
|
+
// a negative deadline is past: its own text ("2 days overdue") says it
|
|
38
|
+
const d = c.deadline && c.deadline.days != null ? ' (' + (c.deadline.days < 0 && c.deadline.text ? c.deadline.text : c.deadline.days < 1 ? 'within a day' : Math.round(c.deadline.days) + 'd') + ')' : '';
|
|
32
39
|
return c.tag + ': ' + c.headline + d;
|
|
33
40
|
});
|
|
34
41
|
}
|
|
@@ -43,6 +50,8 @@ function factsOnce(eng, root, iso) {
|
|
|
43
50
|
return facts;
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
const realLib = () => require('./briefing-real.js');
|
|
54
|
+
|
|
46
55
|
function loadEngine() {
|
|
47
56
|
// the engine is this repo's; `root` only says which repo's initiatives it reads
|
|
48
57
|
const { present } = require(repoScript('signals/index.js'));
|
|
@@ -50,7 +59,8 @@ function loadEngine() {
|
|
|
50
59
|
return { present, readRepoFacts };
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
/** Pure handler: (query, { root, now, load }) -> { code, body }. `load` is injectable for tests.
|
|
62
|
+
/** Pure handler: (query, { root, now, load, real }) -> { code, body }. `load` is injectable for tests; `real` is lib/briefing-real.js
|
|
63
|
+
* build()'s answer, merged around the engine's cards in brief scope. */
|
|
54
64
|
function briefing(query, opts = {}) {
|
|
55
65
|
const q = query || {};
|
|
56
66
|
const root = opts.root || path.resolve(__dirname, '..', '..');
|
|
@@ -71,15 +81,20 @@ function briefing(query, opts = {}) {
|
|
|
71
81
|
// this is usually all four empty: the briefing detectors read promised dates, forecasts, holders and the like, and no
|
|
72
82
|
// initiative record carries them yet. The answer then stays empty and says so by fallback_lens being absent.
|
|
73
83
|
let fallback = false;
|
|
74
|
-
|
|
84
|
+
const real = scope === 'brief' && opts.real ? opts.real : null;
|
|
85
|
+
const hasReal = !!(real && ((real.lead || []).length || (real.tail || []).length));
|
|
86
|
+
if (!cards.length && !q.lens && !hasReal) {
|
|
75
87
|
for (const l of LENSES) {
|
|
76
88
|
if (l === lens) continue;
|
|
77
89
|
const c = eng.present(facts, { lens: l, zoom, today: iso, pages }).cards || [];
|
|
78
90
|
if (c.length > cards.length) { cards = c; useLens = l; fallback = true; }
|
|
79
91
|
}
|
|
80
92
|
}
|
|
93
|
+
const engineCards = cards;
|
|
94
|
+
if (real) cards = realLib().merge(engineCards, real);
|
|
81
95
|
const banner = cards.find((c) => c.pinned && c.tag === 'Critical') || null;
|
|
82
|
-
|
|
96
|
+
const needs = real ? { needs: realLib().needsOf(real, engineCards) } : {};
|
|
97
|
+
return { code: 200, body: { ok: true, scope, lens: useLens, zoom, cards, ticker: tickerOf(cards), banner, ...needs, ...(fallback ? { fallback_lens: true } : {}) } };
|
|
83
98
|
} catch (e) {
|
|
84
99
|
return { code: 500, body: { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) } };
|
|
85
100
|
}
|
|
@@ -103,7 +118,12 @@ module.exports = {
|
|
|
103
118
|
try { facts = await require('./facts-async.js').readFactsAsync(useRoot, { today: iso }); }
|
|
104
119
|
catch (e) { ctx.json(res, { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
|
|
105
120
|
const eng = loadEngine();
|
|
106
|
-
|
|
121
|
+
// the PM's own data (briefing-real-data): today's brief, the assigned work and the Work ledger. A source that fails gives nothing.
|
|
122
|
+
let real = null;
|
|
123
|
+
if ((q.scope ? String(q.scope) : 'brief') === 'brief') {
|
|
124
|
+
try { const src = await realLib().readSources(ctx, now, iso); real = realLib().build({ ...src, today: iso }); } catch { real = null; }
|
|
125
|
+
}
|
|
126
|
+
const out = briefing(q, { root: useRoot, real, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
|
|
107
127
|
// additive: the PM's own overdue and due-today items, as needs rows beside the engine's cards (lib/needs-overdue.js); a read failure leaves the list empty
|
|
108
128
|
if (out.code === 200 && out.body.scope === 'brief') {
|
|
109
129
|
try {
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* EVENTS: lib/calendar-events.js readAll, the imported events joined with the calendar the morning brief remembers and today's
|
|
14
14
|
* brief (the same meetings the Agenda and the Record menu show). With no reader, events = [] and events_source is 'none'. KRS: no KR metric series is stored in the repo, so the strategy lens
|
|
15
|
-
* gets none and says so.
|
|
15
|
+
* gets none and says so.
|
|
16
|
+
*
|
|
17
|
+
* JIRA (initiative briefing-real-data, R7): a gantt row whose initiative has a Jira status snapshot (<state>/tracker/jira/<id>.md, written
|
|
18
|
+
* by lib/jira-status.js) carries `jira`: { done, total, blocked, epics, as_of } (lib/briefing-real.js jiraProgress). The Briefing's pulse
|
|
19
|
+
* shows such a row even without a promised date. No snapshot, no field. */
|
|
16
20
|
const path = require('path');
|
|
17
21
|
const { repoScript } = require('./repo-script.js');
|
|
18
22
|
const model = require(repoScript('calendar-model.js'));
|
|
@@ -22,7 +26,7 @@ const tidyLib = require('./calendar-tidy.js');
|
|
|
22
26
|
const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
|
|
23
27
|
|
|
24
28
|
/** The pure handler. deps: { readFacts(), readEvents?(range), readKrs?(), today, nowMs?, readDated?(range), readItems?(),
|
|
25
|
-
* readContextSources?() } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
|
|
29
|
+
* readContextSources?(), readJira?() → Map<initiative, progress> } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
|
|
26
30
|
function handle(query, deps = {}) {
|
|
27
31
|
const view = (query && query.view) || 'month';
|
|
28
32
|
const date = (query && query.date) || (deps.today || today());
|
|
@@ -38,6 +42,11 @@ function handle(query, deps = {}) {
|
|
|
38
42
|
const b = model.bucketEvents(events, view, date);
|
|
39
43
|
const nowDay = deps.today || today();
|
|
40
44
|
const gantt = model.ganttRows(inis, view, date, { today: nowDay });
|
|
45
|
+
if (typeof deps.readJira === 'function') {
|
|
46
|
+
let jira = null;
|
|
47
|
+
try { jira = deps.readJira(); } catch { jira = null; } // a snapshot folder that cannot be read adds nothing
|
|
48
|
+
if (jira && typeof jira.get === 'function') for (const g of gantt) { const p = jira.get(g.id); if (p) g.jira = p; }
|
|
49
|
+
}
|
|
41
50
|
const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
|
|
42
51
|
const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
|
|
43
52
|
unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
|
|
@@ -94,6 +103,19 @@ function readers(root, runtimeDir) {
|
|
|
94
103
|
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
|
|
95
104
|
return out;
|
|
96
105
|
},
|
|
106
|
+
/* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts are not snapshots) */
|
|
107
|
+
readJira() {
|
|
108
|
+
const fs = require('fs');
|
|
109
|
+
const status = require('./jira-status.js');
|
|
110
|
+
const out = new Map();
|
|
111
|
+
let names = [];
|
|
112
|
+
try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return out; }
|
|
113
|
+
for (const n of names) {
|
|
114
|
+
const id = n.slice(0, -3);
|
|
115
|
+
try { const p = require('./briefing-real.js').jiraProgress(status.readSnapshot(root, id)); if (p) out.set(id, p); } catch { /* one unreadable snapshot is left out */ }
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
},
|
|
97
119
|
readContextSources() {
|
|
98
120
|
const src = { notes: [], planItems: [], ownItems: [] };
|
|
99
121
|
try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
|
|
@@ -23,7 +23,10 @@ const { appendOutbox } = require(repoScript('outbox.js'));
|
|
|
23
23
|
const prefixes = ['/api/runs'];
|
|
24
24
|
const RUN_RE = /^sess-\d+$/;
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// `live` (optional): the server's own session list (ctx.sessionList()), by id. The meta file on disk is not rewritten when a
|
|
27
|
+
// restart finds a session's process gone, so a file can still say it runs days later. When the list is given it decides:
|
|
28
|
+
// a session it calls exited, or does not hold at all, has ended. Its presented state and label ride along for the panel.
|
|
29
|
+
function sessionsOf(sessDir, live) {
|
|
27
30
|
let names = [];
|
|
28
31
|
try { names = fs.readdirSync(sessDir).filter((n) => /^sess-\d+\.json$/.test(n)); } catch { return []; }
|
|
29
32
|
const out = [];
|
|
@@ -31,9 +34,12 @@ function sessionsOf(sessDir) {
|
|
|
31
34
|
let meta; let st;
|
|
32
35
|
try { const f = path.join(sessDir, n); meta = JSON.parse(fs.readFileSync(f, 'utf8')); st = fs.statSync(f); } catch { continue; }
|
|
33
36
|
if (!meta || !meta.id) continue;
|
|
34
|
-
const
|
|
37
|
+
const row = live ? live.get(meta.id) : null;
|
|
38
|
+
const exited = meta.state === 'exited' || (live ? !row || row.state === 'exited' : false);
|
|
39
|
+
const exitedAt = Date.parse(meta.exitedAt);
|
|
35
40
|
out.push({ id: meta.id, plan_item: meta.plan_item || null, name: meta.name || meta.id, origin: meta.origin || 'manual', cardId: meta.plan_item || null,
|
|
36
|
-
startedAt: meta.created || null, endedAt: exited ? st.mtimeMs : null
|
|
41
|
+
startedAt: meta.created || null, endedAt: exited ? (Number.isFinite(exitedAt) ? exitedAt : st.mtimeMs) : null,
|
|
42
|
+
shown: row ? row.shown || null : null, label: row ? row.label || null : null });
|
|
37
43
|
}
|
|
38
44
|
return out;
|
|
39
45
|
}
|
|
@@ -61,7 +67,9 @@ function lastAtOf(lines) {
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
function view(ctx, now = Date.now()) {
|
|
64
|
-
|
|
70
|
+
let live = null;
|
|
71
|
+
if (typeof ctx.sessionList === 'function') { try { live = new Map(ctx.sessionList().map((r) => [r.id, r])); } catch { live = null; } }
|
|
72
|
+
const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'), live);
|
|
65
73
|
const answers = runAnswers.readAnswers(runAnswers.answersFile(ctx.runtimeDir));
|
|
66
74
|
const groups = groupSessions(sessions, now);
|
|
67
75
|
const cards = {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* tracker-cli.js — `pmos tracker …`: record that the tracker-refresh skill finished a read (initiative
|
|
3
|
+
* tracker-read-freshness).
|
|
4
|
+
* pmos tracker done [--moved N] [--left N] [--not-found N] [--json]
|
|
5
|
+
* pmos tracker status [--json]
|
|
6
|
+
* The repository is the one this terminal is in. The record is kept in the per-user runtime folder (lib/tracker-read.js),
|
|
7
|
+
* never in a repository. This command calls no tracker and moves no item.
|
|
8
|
+
*/
|
|
9
|
+
const { execFileSync } = require('child_process');
|
|
10
|
+
const stateRoot = require('./state-root');
|
|
11
|
+
const TR = require('./tracker-read.js');
|
|
12
|
+
|
|
13
|
+
const COMMANDS = { tracker: ['done', 'status'] };
|
|
14
|
+
const USAGE = [
|
|
15
|
+
'usage: pmos tracker done|status — when tracker items were last read for this repository',
|
|
16
|
+
' pmos tracker done [--moved N] [--left N] [--not-found N] the tracker-refresh skill runs this as its last step',
|
|
17
|
+
' pmos tracker status when the last read finished, and what it found',
|
|
18
|
+
' every command: [--json]',
|
|
19
|
+
'This command never calls a tracker and moves no item.',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const out = { _: [] };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const t = argv[i];
|
|
26
|
+
if (!t.startsWith('--')) { out._.push(t); continue; }
|
|
27
|
+
const eq = t.indexOf('=');
|
|
28
|
+
const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
|
|
29
|
+
if (key === 'json' || key === 'help') out[key] = true;
|
|
30
|
+
else if (eq > 0) out[key] = t.slice(eq + 1);
|
|
31
|
+
else if (argv[i + 1] !== undefined) out[key] = argv[++i];
|
|
32
|
+
else out[key] = '';
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function repoRoot(cwd) {
|
|
38
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
|
|
39
|
+
catch { return cwd; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(argv, { say = console.log, env = process.env, stateDir, cwd = process.cwd(), now = () => new Date() } = {}) {
|
|
43
|
+
const a = parseArgs(argv.slice(1));
|
|
44
|
+
const sub = a._[0];
|
|
45
|
+
const out = (o, line) => { if (a.json) say(JSON.stringify(o)); else say(line); };
|
|
46
|
+
if (!sub || a.help || sub === 'help') { USAGE.forEach((l) => say(l)); return sub || a.help ? 0 : 2; }
|
|
47
|
+
if (!COMMANDS.tracker.includes(sub)) { say(`✗ pmos tracker ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
|
|
48
|
+
const dir = stateDir || stateRoot.defaultRoot({ env });
|
|
49
|
+
const root = repoRoot(cwd);
|
|
50
|
+
|
|
51
|
+
if (sub === 'done') {
|
|
52
|
+
const counts = {};
|
|
53
|
+
for (const [flag, key] of [['moved', 'moved'], ['left', 'left'], ['not-found', 'not_found']]) {
|
|
54
|
+
if (a[flag] === undefined) continue;
|
|
55
|
+
const n = Number(a[flag]);
|
|
56
|
+
if (!Number.isInteger(n) || n < 0) { out({ ok: false, error: `--${flag} takes a whole number of 0 or more` }, `✗ --${flag} takes a whole number of 0 or more`); return 2; }
|
|
57
|
+
counts[key] = n;
|
|
58
|
+
}
|
|
59
|
+
const rec = TR.write(dir, root, counts, now());
|
|
60
|
+
out({ ok: true, ...rec }, `tracker items read at ${rec.read_at} for ${rec.root}. The freshness line shows it.`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
const rec = TR.read(dir, root);
|
|
64
|
+
out({ ok: true, record: rec }, rec ? `tracker items last read at ${rec.read_at} (moved ${rec.moved ?? '?'}, left ${rec.left ?? '?'}, not found ${rec.not_found ?? '?'})` : 'tracker items not read yet for this repository');
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* tracker-read.js — when the tracker-refresh skill last finished a read of one repository (initiative
|
|
3
|
+
* tracker-read-freshness).
|
|
4
|
+
*
|
|
5
|
+
* write(runtimeDir, root, { moved, left, not_found }, now) → the record `pmos tracker done` runs this
|
|
6
|
+
* read(runtimeDir, root) → the record or null
|
|
7
|
+
* readAtMs(runtimeDir, root) → ms, or 0
|
|
8
|
+
*
|
|
9
|
+
* The skill reads local files only and moves an item only when a file says so, so a read that changes nothing writes no
|
|
10
|
+
* ledger line. The freshness line needs to know the read happened all the same. Its last step stamps one small file per
|
|
11
|
+
* repository in the per-user runtime folder: <runtime>/tracker-read/<hash of the repository path>.json. It never goes into
|
|
12
|
+
* a repository, and the bi-weekly report does not read it.
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
19
|
+
const fileOf = (runtimeDir, root) =>
|
|
20
|
+
path.join(runtimeDir, 'tracker-read', crypto.createHash('sha1').update(real(root)).digest('hex').slice(0, 16) + '.json');
|
|
21
|
+
const count = (n) => (Number.isInteger(n) && n >= 0 ? n : null);
|
|
22
|
+
|
|
23
|
+
function write(runtimeDir, root, counts = {}, now = new Date()) {
|
|
24
|
+
const rec = { root: real(root), read_at: now.toISOString(), moved: count(counts.moved), left: count(counts.left), not_found: count(counts.not_found) };
|
|
25
|
+
const f = fileOf(runtimeDir, root);
|
|
26
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
27
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify(rec) + '\n');
|
|
29
|
+
fs.renameSync(tmp, f);
|
|
30
|
+
return rec;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function read(runtimeDir, root) {
|
|
34
|
+
if (!runtimeDir || !root) return null;
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(fileOf(runtimeDir, root), 'utf8')); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const readAtMs = (runtimeDir, root) => { const r = read(runtimeDir, root); return (r && Date.parse(r.read_at)) || 0; };
|
|
39
|
+
|
|
40
|
+
module.exports = { write, read, readAtMs, fileOf };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.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.147.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -95,6 +95,8 @@ export function dayPartFor(date) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
|
|
98
|
+
/* a link a card or a need may open: https only, anything else is dropped (initiative briefing-real-data) */
|
|
99
|
+
const httpsUrl = (v) => (typeof v === 'string' && /^https:\/\/\S+$/.test(v) && v.length <= 500 ? v : null);
|
|
98
100
|
|
|
99
101
|
/** What the deck draws for one engine card. */
|
|
100
102
|
export function cardView(card) {
|
|
@@ -134,6 +136,9 @@ export function cardView(card) {
|
|
|
134
136
|
kpisX: Array.isArray(c.kpis) ? c.kpis.filter((k) => Array.isArray(k)).slice(0, 3).map((k) => ({ v: String(k[0]), l: String(k[1]), c: k[2] ? String(k[2]) : null })) : null,
|
|
135
137
|
factsX: Array.isArray(c.factsX) ? c.factsX.filter((f) => Array.isArray(f)).slice(0, 3).map((f) => ({ icon: String(f[0]), text: String(f[1]) })) : null,
|
|
136
138
|
rich: h.rich && typeof h.rich === 'object' ? h.rich : null,
|
|
139
|
+
/* where a card from the PM's own data came from (brief, jira, wbs, work) and the record's link, opened from the case file (briefing-real-data) */
|
|
140
|
+
origin: c.origin ? String(c.origin) : null,
|
|
141
|
+
url: httpsUrl(c.url), urlLabel: c.url_label ? String(c.url_label) : null,
|
|
137
142
|
conf: rec ? num(rec.conf) : null,
|
|
138
143
|
terms: w ? { impact: num(w.impact), urgency: num(w.urgency), irreversible: num(w.irreversible), score: num(w.score) } : null,
|
|
139
144
|
};
|
|
@@ -190,6 +195,7 @@ export function caseModelOf(v, o = {}) {
|
|
|
190
195
|
unsure: [c.unsure, c.template && c.template.gaps.length ? 'Not known yet: ' + c.template.gaps.map((g) => GAP_WORDS[g] || g).join(', ') + '.' : ''].filter(Boolean).join(' '),
|
|
191
196
|
ifYouWait: dl ? (dl.days == null ? 'No deadline is known: ' + dl.text : dl.text) : '', why: c.why || '',
|
|
192
197
|
outward: !sim, outboxReason: NO_ROUTE,
|
|
198
|
+
...(c.url ? { go: { action: 'open', href: c.url, label: c.urlLabel || 'Open the record' } } : {}),
|
|
193
199
|
};
|
|
194
200
|
}
|
|
195
201
|
|
|
@@ -198,10 +204,15 @@ export function needCaseOf(n) {
|
|
|
198
204
|
const reg = registered('need', n.key);
|
|
199
205
|
if (reg) return reg;
|
|
200
206
|
if (n.view) return caseModelOf(n.view, { sim: !!n.sim }); // a real row is one engine card: its case is the card's own
|
|
207
|
+
/* a row from the PM's own data (briefing-real-data) carries its facts, its reason and its link; every string is the row's own */
|
|
208
|
+
const facts = Array.isArray(n.facts) && n.facts.length ? n.facts.map((f) => ({ icon: f.icon || 'info', text: String(f.text || ''), source: f.source || '', tone: f.tone || '' })) : null;
|
|
209
|
+
const url = httpsUrl(n.url);
|
|
201
210
|
return {
|
|
202
211
|
id: 'need:' + n.key, situation: n.headline,
|
|
203
|
-
evidence: [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
|
|
212
|
+
evidence: facts || [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
|
|
204
213
|
options: [], outward: true, outboxReason: NO_ROUTE,
|
|
214
|
+
...(n.why ? { why: String(n.why) } : {}), ...(n.dueText && facts ? { ifYouWait: String(n.dueText) } : {}),
|
|
215
|
+
...(url ? { go: { action: 'open', href: url, label: n.url_label ? String(n.url_label) : 'Open the record' } } : {}),
|
|
205
216
|
};
|
|
206
217
|
}
|
|
207
218
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/* pm/c3-brief-needs.js — the Briefing's "Needs you" (PRD briefing-brief R3, R4, R6).
|
|
2
2
|
* needsSection(S) the section: own items first (overdue, most late first; then due today; then outside PMOS), then the decisions only the PM
|
|
3
|
-
* can make (the briefing's needs
|
|
3
|
+
* can make (c3-brief.js needRow): the briefing's `needs`. In real mode GET /api/briefing builds them (initiative briefing-real-data):
|
|
4
|
+
* the assigned Jira and WBS items that are overdue, blocked, a decision or due today, the engine cards that carry a move or are
|
|
5
|
+
* Critical (a row with `card`, drawn from the card), and the morning brief's worth asking items. Without `needs` (an older
|
|
6
|
+
* server), the engine cards that carry a move or are Critical; the cards from the PM's own data (`origin`) are not decisions.
|
|
4
7
|
* itemRow(row, ctx) a row of an own item. It opens in place (c3-expand). Its actions write to the item's home record, through the pm-items routes:
|
|
5
8
|
* Done POST /api/pm-items/done { id, outcome? }
|
|
6
9
|
* Not yet POST /api/pm-items/notyet { id, when: 'later-today' | 'tomorrow' | 'next-week' } the item returns on that date
|
|
@@ -81,11 +84,20 @@ export function itemRow(r, ctx = {}) {
|
|
|
81
84
|
/* The Needs you count for the Briefing tab's badge, read without the section: the decisions (the briefing's needs, or its engine cards that carry a move or are
|
|
82
85
|
* Critical) plus the own items of the brief page. The section's own count (recount) takes over once it is on the page. */
|
|
83
86
|
export function needsCount(b, page) {
|
|
84
|
-
const decide = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key]).length : ((b && b.cards) || []).filter(
|
|
87
|
+
const decide = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key]).length : ((b && b.cards) || []).filter(isDecision).length;
|
|
85
88
|
const own = page && page.ok !== false ? (page.needs || []).filter((r) => r.kind !== 'decision').length : 0;
|
|
86
89
|
return decide + own;
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/* an engine card a PM decides on; a card from the PM's own data (origin) is a reminder, and its row comes in `needs` instead */
|
|
93
|
+
const isDecision = (c) => !!c && !c.origin && !!(c.move || c.tag === 'Critical');
|
|
94
|
+
/* a needs row that carries an engine card is drawn from the card, as the cards-only path draws it */
|
|
95
|
+
function fromCard(n, i) {
|
|
96
|
+
if (!n || !n.card) return n;
|
|
97
|
+
const v = cardView(n.card); const T = TAG_STYLE[v.tag] || TAG_STYLE['Just in'];
|
|
98
|
+
return { key: n.key || v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v };
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
export function needsSection(S) {
|
|
90
102
|
const count = el('span', { class: 'bdg g' }, '0');
|
|
91
103
|
const quick = el('span', { class: 'chips2 dim mono c3s2', 'data-needs-summary': '' });
|
|
@@ -133,8 +145,8 @@ export function needsSection(S) {
|
|
|
133
145
|
const fill = (b) => {
|
|
134
146
|
last = b;
|
|
135
147
|
const inc = !!S.incident;
|
|
136
|
-
let needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
|
|
137
|
-
const decide = ((b && b.cards) || []).filter(
|
|
148
|
+
let needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.map(fromCard).filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
|
|
149
|
+
const decide = ((b && b.cards) || []).filter(isDecision);
|
|
138
150
|
const key = needs ? needs.map((n) => n.key + n.headline) : decide.map((c) => (c.key || '') + c.headline);
|
|
139
151
|
if (needs && S.pinNeed) { const at = needs.findIndex((n) => n.key === S.pinNeed); if (at > 0) needs.unshift(...needs.splice(at, 1)); }
|
|
140
152
|
const g = JSON.stringify([key, inc, !!(b && b.kept_out), S.pinNeed || '']);
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* One status bar and a row per initiative (the Calendar's own gantt rows, GET /api/calendar-views quarter, c3-plan.js readQuarter; the demo's pulse rows
|
|
3
3
|
* carry their own colour and reason). A row opens in place with the case component the Needs you rows use (c3-expand + c3-case): why it is where it
|
|
4
4
|
* is, promised against forecast. A press on a row does NOT change the tab. "Open initiative" is an explicit link inside the opened row and is the only
|
|
5
|
-
* thing here that does. Rows are references (data-ref-row): nothing in them changes a record.
|
|
5
|
+
* thing here that does. Rows are references (data-ref-row): nothing in them changes a record.
|
|
6
|
+
* Jira (initiative briefing-real-data, R7): a gantt row with a `jira` field (its Jira epic's snapshot: done, total, blocked, as_of) shows even
|
|
7
|
+
* without a promised date, as "x of y done" with a bar; a row with a plan keeps its look and adds the Jira line to its case. */
|
|
6
8
|
import { el } from './dom.js';
|
|
7
9
|
import { c3ic } from './pm-work-view.js';
|
|
8
10
|
import { makeXp, attachExpand } from './c3-expand.js';
|
|
9
11
|
import { caseFile, hooks } from './c3-brief.js';
|
|
10
12
|
import { readQuarter } from './c3-plan.js';
|
|
11
|
-
import { row, signal, button, ensureComponentsCss } from './primitives.js';
|
|
13
|
+
import { row, signal, button, meter, ensureComponentsCss } from './primitives.js';
|
|
12
14
|
import { ensureSprite } from './icons.js';
|
|
13
15
|
|
|
14
16
|
/* tone: ok is neutral, warn is the warning token, bad the destructive one (R28-12) */
|
|
@@ -17,8 +19,32 @@ const RAG = { red: ['bad', 'Off track'], amber: ['warn', 'At risk'], green: ['ok
|
|
|
17
19
|
const SIG = { bad: 'destructive', warn: 'warning' };
|
|
18
20
|
const end = (o) => (o && (o.end || o.start)) || null;
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
/* a usable Jira progress: at least one ticket under the epic */
|
|
23
|
+
const jiraOf = (g) => (g && g.jira && Number.isInteger(g.jira.total) && g.jira.total > 0 && Number.isInteger(g.jira.done) ? g.jira : null);
|
|
24
|
+
const jiraLines = (j) => {
|
|
25
|
+
const ev = [['check', j.done + ' of ' + j.total + ' done in Jira' + (j.epics && j.epics.length ? ' (epic ' + j.epics.join(', ') + ')' : ''), 'Jira']];
|
|
26
|
+
if (j.blocked > 0) ev.push(['alert', j.blocked + ' blocked', 'Jira', 'warn']);
|
|
27
|
+
if (j.as_of) ev.push(['clock', 'Read from Jira ' + String(j.as_of).slice(0, 10), 'Jira']);
|
|
28
|
+
return ev;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** One gantt row as the pulse draws it: { id, title, cls, word, color, why, evidence, progress? }. Pure. */
|
|
21
32
|
export function pulseRow(g) {
|
|
33
|
+
const j = jiraOf(g);
|
|
34
|
+
if (j && !g.pulse && (!PST[g.status] || g.status === 'no plan')) {
|
|
35
|
+
/* no plan date: the row is the epic's progress (briefing-real-data R7) */
|
|
36
|
+
const all = j.done >= j.total;
|
|
37
|
+
const cls = j.blocked > 0 ? 'warn' : all ? 'ok' : '';
|
|
38
|
+
const word = j.blocked > 0 ? 'Blocked' : all ? 'Done in Jira' : 'In progress';
|
|
39
|
+
const why = j.done + ' of ' + j.total + ' done in Jira' + (j.blocked > 0 ? ', ' + j.blocked + ' blocked' : '') + '; no promised date';
|
|
40
|
+
return { id: g.id, title: g.title || g.id, cls, word, why, evidence: jiraLines(j), progress: { done: j.done, total: j.total } };
|
|
41
|
+
}
|
|
42
|
+
const r = planRow(g);
|
|
43
|
+
if (j) { r.evidence = r.evidence.concat(jiraLines(j)); r.progress = { done: j.done, total: j.total }; }
|
|
44
|
+
return r;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function planRow(g) {
|
|
22
48
|
if (g.pulse && RAG[g.pulse.c]) {
|
|
23
49
|
const [cls, word] = RAG[g.pulse.c];
|
|
24
50
|
const ev = [];
|
|
@@ -53,9 +79,10 @@ export function pulse(S) {
|
|
|
53
79
|
const draw = (rows, err) => {
|
|
54
80
|
if (err) { body.replaceChildren(el('p', { class: 'dim mono c3s2', role: 'status' }, 'The initiatives could not be read: ' + err)); return; }
|
|
55
81
|
if (!rows.length) { body.replaceChildren(note('No initiative has a promised date yet, so there is nothing to compare with the forecast.')); return; }
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
82
|
+
const shown = (g) => g.pulse || g.status !== 'no plan' || !!jiraOf(g); // a row without a plan shows when its Jira epic has progress
|
|
83
|
+
const noPlan = rows.filter((g) => !shown(g)).length;
|
|
84
|
+
rows = rows.filter(shown);
|
|
85
|
+
if (!rows.length) { body.replaceChildren(note('No initiative has a promised date or a Jira epic read yet, so there is nothing to show.'), ...(noPlan ? [el('p', { class: 'dim mono c3s2' }, noPlan + ' initiatives have no plan date.')] : [])); return; }
|
|
59
86
|
const pr = rows.map(pulseRow);
|
|
60
87
|
const need = pr.filter((r) => r.cls === 'bad' || r.cls === 'warn').length;
|
|
61
88
|
attn.textContent = need + ' need attention'; foot.hidden = false;
|
|
@@ -65,7 +92,8 @@ export function pulse(S) {
|
|
|
65
92
|
const rowEl = (r) => {
|
|
66
93
|
const xp = makeXp(caseFile(pulseCase(r)));
|
|
67
94
|
let ex = null;
|
|
68
|
-
const
|
|
95
|
+
const bar = r.progress ? [el('span', { 'data-pulse-progress': r.progress.done + '/' + r.progress.total }, meter({ done: r.progress.done, total: r.progress.total, label: r.progress.done + ' of ' + r.progress.total + ' done' }))] : null;
|
|
96
|
+
const head = row({ title: r.title, meta: [r.why], signals: [signal({ text: r.word, tone: SIG[r.cls] || 'muted' })], trail: bar, openLabel: r.title + ': ' + r.word, onOpen: () => ex.toggle(), openAttrs: { 'data-c3pg-prow': r.id } });
|
|
69
97
|
ex = attachExpand(head, xp, { key: 'pulse-' + r.id });
|
|
70
98
|
return el('div', { class: 'pwrap', 'data-ref-row': '', 'data-pulse-row': r.id }, head, xp);
|
|
71
99
|
};
|