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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-sessions-panel-test.js +126 -0
  7. package/workspace/guide/briefing-cards.md +2 -2
  8. package/workspace/guide/briefing.md +19 -1
  9. package/workspace/guide/console-calendar.md +18 -3
  10. package/workspace/guide/console3-sessions.md +35 -0
  11. package/workspace/guide/data-freshness.md +7 -0
  12. package/workspace/guide/index.md +1 -0
  13. package/workspace/lib/briefing-real.js +264 -0
  14. package/workspace/lib/calendar-layers.js +5 -2
  15. package/workspace/lib/freshness.js +4 -1
  16. package/workspace/lib/pmos-verbs.js +3 -0
  17. package/workspace/lib/routes-briefing.js +25 -5
  18. package/workspace/lib/routes-calendar-views.js +24 -2
  19. package/workspace/lib/routes-runs.js +12 -4
  20. package/workspace/lib/tracker-cli.js +68 -0
  21. package/workspace/lib/tracker-read.js +40 -0
  22. package/workspace/package-lock.json +2 -2
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/briefing-model.js +12 -1
  25. package/workspace/public/pm/c3-brief-needs.js +16 -4
  26. package/workspace/public/pm/c3-brief-pulse.js +35 -7
  27. package/workspace/public/pm/c3-calendar.js +162 -25
  28. package/workspace/public/pm/c3-case.js +7 -2
  29. package/workspace/public/pm/c3-demo-cal4.js +3 -3
  30. package/workspace/public/pm/c3-dock.js +2 -1
  31. package/workspace/public/pm/c3-session-term.js +44 -0
  32. package/workspace/public/pm/c3-sheet.js +3 -2
  33. package/workspace/public/pm/c3-shell.js +2 -1
  34. package/workspace/public/pm/c3-terminal-data.js +1 -1
  35. package/workspace/public/pm/c3-terminal-icons.js +1 -1
  36. package/workspace/public/pm/console3.css +24 -0
  37. package/workspace/public/pm/run-card.js +4 -1
  38. package/workspace/public/pm/sessions-panel.js +40 -4
  39. package/workspace/skills/tracker-refresh.skill +2 -1
  40. 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
- out.push(row(S.tracker, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0));
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
- const d = c.deadline && c.deadline.days != null ? ' (' + (c.deadline.days < 1 ? 'within a day' : Math.round(c.deadline.days) + 'd') + ')' : '';
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
- if (!cards.length && !q.lens) {
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
- return { code: 200, body: { ok: true, scope, lens: useLens, zoom, cards, ticker: tickerOf(cards), banner, ...(fallback ? { fallback_lens: true } : {}) } };
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
- const out = briefing(q, { root: useRoot, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
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
- function sessionsOf(sessDir) {
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 exited = meta.state === 'exited';
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
- const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'));
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.145.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.145.0",
9
+ "version": "0.147.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.145.0",
3
+ "version": "0.147.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",
@@ -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 in the demo, the engine cards that carry a move or are Critical in real mode: c3-brief.js needRow).
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((c) => c && (c.move || c.tag === 'Critical')).length;
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((c) => c && (c.move || c.tag === 'Critical'));
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
- /** One gantt row as the pulse draws it: { id, title, cls, word, color, why, evidence }. Pure. */
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 noPlan = rows.filter((g) => !g.pulse && g.status === 'no plan').length;
57
- rows = rows.filter((g) => g.pulse || g.status !== 'no plan');
58
- if (!rows.length) { body.replaceChildren(note('No initiative has a promised date yet, so there is nothing to compare with the forecast.'), ...(noPlan ? [el('p', { class: 'dim mono c3s2' }, noPlan + ' initiatives have no plan date.')] : [])); return; }
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 head = row({ title: r.title, meta: [r.why], signals: [signal({ text: r.word, tone: SIG[r.cls] || 'muted' })], openLabel: r.title + ': ' + r.word, onOpen: () => ex.toggle(), openAttrs: { 'data-c3pg-prow': r.id } });
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
  };