create-pmos 0.211.0 → 0.212.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.
@@ -77,7 +77,12 @@
77
77
  .c3 .c3top{position:absolute;top:0;right:0;left:auto;align-items:center !important;gap:10px;z-index:3;padding:0 !important;margin:0 !important;min-height:44px;background:transparent}
78
78
  .c3 .c3top > div:first-child{display:flex;align-items:center;gap:8px;order:1}
79
79
  .c3 .c3top .sub{font-size:var(--fs-1);margin:0}
80
- .c3 .c3top .fr-fresh.qt-open{position:absolute;top:44px;right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;flex-wrap:wrap;max-width:560px;box-shadow:0 6px 24px rgb(0 0 0 / .08)}
80
+ .c3 .c3top .fr-fresh.qt-open{position:absolute;top:44px;right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;box-shadow:0 6px 24px rgb(0 0 0 / .08)}
81
+ /* The open line is a list: one source per row, then the refresh icon with its note. Wrapped inline, a row broke mid-list and left a dot at its end. */
82
+ .c3 .c3top .fr-fresh.qt-open{display:grid !important;grid-template-columns:auto 1fr;align-items:center;gap:6px 8px;width:max-content;max-width:min(360px,calc(100vw - 24px));white-space:nowrap}
83
+ .c3 .c3top .fr-fresh.qt-open > [data-fr-line]{grid-column:1 / -1}
84
+ .c3 .c3top .fr-fresh.qt-open > .fr-fresh__sep{display:none}
85
+ .c3 .c3top .fr-fresh.qt-open > .fr-fresh__note{white-space:normal}
81
86
  .c3 nav.c3tabs{margin-top:0 !important;min-height:44px}
82
87
  /* Row 2: one context bar per page */
83
88
  .c3 .qt-ctx{display:flex !important;align-items:center;gap:10px;flex-wrap:wrap}
@@ -72,3 +72,14 @@
72
72
  /* The effects of two Day and time settings, on the console root. */
73
73
  [data-c3-tint="off"][data-daypart]{background-image:none!important}
74
74
  [data-c3-strip="off"] .c3pg-daylight{display:none!important}
75
+ /* Data refresh: the source icon sits in the title line; the schedule editor opens under the row. */
76
+ .c3 .c3set .rf-row .tt{display:flex;align-items:center;gap:var(--sp-2)}
77
+ .c3 .c3set .rf-ed{margin-top:var(--sp-3);padding-top:var(--sp-2);border-top:1px solid var(--border)}
78
+ .c3 .c3set .rf-ed .acts select+select,.c3 .c3set .rf-ed .acts select{margin-right:4px}
79
+ .c3 .c3set .rf-ed .mk-lbl{margin:var(--sp-2) 0 2px;text-transform:uppercase}
80
+ .c3 .c3set .rf-row .fold .c3set-b{margin-left:auto}
81
+ @media (max-width:860px){
82
+ .c3 .c3set .list>.nrow.st-row.rf-row{grid-template-columns:minmax(0,1fr)}
83
+ .c3 .c3set .list>.nrow.st-row.rf-row>.acts{justify-content:flex-start}
84
+ }
85
+ .c3.ph .c3set .nrow.st-row .acts{grid-column:auto;justify-content:flex-start}
@@ -16,6 +16,7 @@
16
16
  * about you GET and POST /api/proactive/settings {name, about:{role, work, duties}} (the Welcome page writes the same)
17
17
  * running sessions GET /api/sessions calendar import POST /api/sources/refresh {source:'calendar'}
18
18
  * versions GET /api/kit/status · POST /api/kit/check, /api/kit/update, /api/kit/upgrade-app (then GET /api/sessions until the new version)
19
+ * data refresh GET /api/refresh?ws · POST /api/refresh/source, /rules, /run, /run-all · GET /api/refresh/log, /log.csv
19
20
  * Every other setting is in the settings store: GET /api/settings and POST /api/settings (lib/settings-store.js).
20
21
  * A board's own workflow is edited on the board; here are only the templates new boards start from.
21
22
  * Nothing here asks for or shows a key or a password. */
@@ -38,6 +39,7 @@ export const SECTIONS = Object.freeze([
38
39
  ['workspaces', 'Workspaces', 'repo', 'A workspace is one repository PMOS works in. Settings marked Workspace apply to the one picked at the top of this page.'],
39
40
  ['boards', 'Boards and workflows', 'cols', 'The templates and defaults new boards start from. A board\'s own workflow is edited on the board, in Initiatives.'],
40
41
  ['connectors', 'Connectors', 'org', 'PMOS keeps no passwords or keys for these. Your Claude session reaches each tool through the connectors of its profile.'],
42
+ ['refresh', 'Data refresh', 'undo', 'What PMOS reads and writes on a schedule: pick what refreshes, when and how often, and see when each one last ran.'],
41
43
  ['recording', 'Recording', 'mic', 'How meetings are transcribed. This is the one place to check when a transcript looks wrong.'],
42
44
  ['time', 'Day and time', 'sun', 'The Briefing follows your clock. These settings say what your day looks like.'],
43
45
  ['sharing', 'Sharing', 'mail', 'Who reports go to, and how. Each report\'s schedule is set in Reports, under Set up.'],
@@ -55,9 +57,7 @@ export const LABELS = Object.freeze({
55
57
  'board.template': ['Defaults', 'Template for new boards', 'A new board copies this template. You can change its workflow on the board afterwards.', { product: 'Product', project: 'Project', simple: 'Simple' }],
56
58
  'board.rules': ['Defaults', 'Order rules on boards', 'You move cards between stages. When a card skips a step, such as Build before a spec exists, the card shows a warning (decision D3).', { warn: 'Warn on the card', off: 'Off' }],
57
59
  'conn.cal.window': ['Google Calendar', 'Import window', 'How far back and ahead your session imports events.', { '7-14': 'Past 7 days, next 14', '14-30': 'Past 14 days, next 30', '30-60': 'Past 30 days, next 60' }],
58
- 'conn.cal.refresh': ['Google Calendar', 'Refresh', 'When your session imports events again.', { open: 'When I open Calendar', hour: 'Every hour', manual: 'Only when I import' }],
59
60
  'conn.cal.private': ['Google Calendar', 'Private events', 'Events marked private in Google Calendar.', { busy: 'Show as busy', hide: 'Hide', title: 'Show titles' }],
60
- 'conn.jira.refresh': ['Jira', 'Refresh ticket status', 'Jira owns ticket status. PMOS reads it back on this schedule.', { open: 'When I open a board', 15: 'Every 15 min', manual: 'Only when I sync' }],
61
61
  'conn.wispr.wait': ['Wispr Flow', 'Wait before fetching', 'Wispr Flow needs a few minutes to finish its notes after a meeting.', { 2: '2 minutes', 5: '5 minutes', 10: '10 minutes' }],
62
62
  'conn.sender': ['Sending', 'Sender for shared dashboards', 'Your session sends through this connector. Until you pick one, a scheduled share is a ready draft.', { none: 'None', slack: 'Slack', gmail: 'Gmail drafts' }],
63
63
  'rec.sources': ['Live transcript', 'Listen to', 'The meeting is the sound from your call app. The mic is you.', { both: 'Mic and meeting', mic: 'Mic only', meeting: 'Meeting only' }],
@@ -140,7 +140,7 @@ const bt = (attrs, ...kids) => {
140
140
 
141
141
  const ZONES = ['Asia/Jakarta', 'Asia/Makassar', 'Asia/Jayapura', 'Asia/Singapore', 'Asia/Kuala_Lumpur', 'Asia/Bangkok', 'Asia/Tokyo', 'Asia/Kolkata', 'Australia/Sydney', 'Europe/London', 'Europe/Berlin', 'America/New_York', 'America/Los_Angeles', 'UTC'];
142
142
  /* Choices the mockup draws as a select even though they are short. */
143
- const AS_SELECT = new Set(['conn.cal.window', 'conn.cal.refresh', 'conn.jira.refresh', 'conn.wispr.wait']);
143
+ const AS_SELECT = new Set(['conn.cal.window', 'conn.wispr.wait']);
144
144
  const qs = (o) => Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
145
145
 
146
146
  export function mountSettings(host, ctx = {}) {
@@ -149,6 +149,7 @@ export function mountSettings(host, ctx = {}) {
149
149
  sec: SECTIONS.some((s) => s[0] === ctx.section) ? ctx.section : 'about', q: '', ws: ctx.ws || '', data: null, err: {},
150
150
  engine: null, people: null, consent: null, online: null, profiles: null, typesafe: null, kits: {}, kitBusy: null, app: { phase: null, error: '' }, about: null, running: null,
151
151
  tplEdit: null, wsEdit: null, wsDrop: null, addPath: '',
152
+ rf: null, rfLog: null, rfSig: '', rfFilter: 'all', rfEdit: null, rfAsk: null, rfErr: {}, rfAllBusy: false, rfLoading: false, logSource: '', logResult: '',
152
153
  };
153
154
  const root = el('div', { class: 'brief c3set', 'data-c3set': '' });
154
155
  const nav = el('ul', { class: 'list', role: 'list', 'data-c3set-nav': '' });
@@ -181,7 +182,7 @@ export function mountSettings(host, ctx = {}) {
181
182
  }
182
183
  async function loadEngine() { const r = await pmGet('/api/notes/engine?' + qs({ ws: M.ws })); M.engine = r && r.ok ? r : { error: (r && r.error) || 'not read' }; draw(); }
183
184
  async function loadWs() {
184
- M.people = null; M.consent = null; draw();
185
+ M.people = null; M.consent = null; M.rf = null; M.rfLog = null; M.rfEdit = null; M.rfAsk = null; draw();
185
186
  await Promise.all([loadStore(), loadEngine(),
186
187
  M.ws ? pmGet('/api/people?' + qs({ ws: M.ws })).then((r) => { M.people = r && r.ok !== false && r.settings ? { on: !!r.settings.calendar_names } : { error: (r && r.error) || 'not read' }; }) : null,
187
188
  M.ws ? pmGet('/api/validate/repo?' + qs({ ws: M.ws })).then((r) => { M.consent = r && r.ok !== false ? { on: !!r.consent } : { error: (r && r.error) || 'not read' }; }) : null]);
@@ -263,7 +264,7 @@ export function mountSettings(host, ctx = {}) {
263
264
  el('div', { class: 'nb' },
264
265
  forId ? el('label', { class: 'tt', for: forId }, label) : el('span', { class: 'tt' }, label),
265
266
  scope || changed ? el('div', { class: 'chips' }, scope ? scopeTag(scope) : null, changed ? el('span', { class: 'st-dot', title: 'Changed from the default' }) : null) : null,
266
- h ? help(h) : null,
267
+ h ? help(...[].concat(h)) : null,
267
268
  env ? el('div', { class: 'fold w', 'data-c3set-env': env.env }, c3ic('alert', 'sm'), el('span', null, el('b', { class: 'mono' }, env.env + '=' + env.value), ' is set where PMOS starts. ' + env.effect + ' Remove it from your shell profile, then restart PMOS.')) : null,
268
269
  lock ? fold('lock', lock) : null,
269
270
  note ? help(note) : null,
@@ -403,6 +404,7 @@ export function mountSettings(host, ctx = {}) {
403
404
  const profs = (M.profiles && M.profiles.profiles) || [];
404
405
  return select(id, 'Default profile', [['', 'None'], ...profs.map((x) => [x.id, x.label && x.label !== x.id ? x.label + ' (' + x.id + ')' : x.id])], profileNow().id || '', chooseProfile);
405
406
  };
407
+ const refreshLink = (key, label, h) => frame({ key, label, help: h, control: secLink('refresh', 'Data refresh') });
406
408
  function connectorsSec() {
407
409
  const { id, x } = profileNow();
408
410
  const mcp = x ? x.mcp : [];
@@ -423,13 +425,13 @@ export function mountSettings(host, ctx = {}) {
423
425
  help('Profiles are listed in ', secLink('sessions', 'Claude sessions'), '.')], { ic: 'user', attrs: { 'data-c3set-profile': '' } }),
424
426
  sect('Google Calendar', [list([
425
427
  notYet('conn.cal.list', 'Calendars to import', 'Pick which of your calendars the import reads.', 'Choosing calendars comes later. Today the import reads the calendars your session can see.', 'computer'),
426
- storeRow('conn.cal.window'), storeRow('conn.cal.refresh'), storeRow('conn.cal.private'),
428
+ storeRow('conn.cal.window'), refreshLink('conn.cal.refresh', 'Refresh schedule', 'When the calendar is read again is set in Data refresh.'), storeRow('conn.cal.private'),
427
429
  M.ws ? extRow('people.names', people && !people.error ? swBtn(people.on, EXT['people.names'][2], async () => { const r = await pmPost('/api/people/decide', { ws: M.ws, act: 'setting', calendar_names: !people.on }); if (r && r.ok !== false) { people.on = !people.on; toast('Saved: attendee names'); } else M.err['people.names'] = (r && r.error) || 'Not saved.'; draw(); }) : el('span', { class: 'dim' }, people ? people.error : 'Reading'), { scope: 'ws' }) : null]),
428
430
  el('div', { class: 'iacts' }, btn('Import now', { ic: 'undo', disabled: !M.ws || !has(/calendar/i), onclick: async () => { const r = await pmPost('/api/sources/refresh', { ws: M.ws, source: 'calendar' }); toast(r && r.ok ? 'Import started in your Claude session. Events appear in Calendar when it finishes.' : ((r && r.error) || 'The import did not start.')); } }),
429
431
  el('span', { class: 'dim st-help' }, via(has(/calendar/i), 'calendar') + ' PMOS never edits your calendar.'))], { ic: 'cal', right: reach(has(/calendar/i)) }),
430
432
  sect('Jira', [list([notYet('jira.map', 'Jira project for each board', 'A board backed by a Jira project mirrors its sprints and reads ticket status from it, with each Jira status mapped to a board stage.', 'Mapping boards and statuses comes with the Initiatives update.', 'board')]),
431
433
  help(via(has(/atlassian|jira/i), 'Jira'))], { ic: 'org', right: reach(has(/atlassian|jira/i)) }),
432
- group('Jira behaviour', [storeRow('conn.jira.refresh'),
434
+ group('Jira behaviour', [refreshLink('conn.jira.refresh', 'Refresh ticket status', 'When Jira is read again is set in Data refresh.'),
433
435
  lockedRow('jira.push', 'Creating or updating tickets', 'PMOS drafts tickets from the PRD. Nothing reaches Jira until you select it (D88).', 'You approve every push', 'ws'),
434
436
  lockedRow('jira.sprints', 'Sprints and burndown', 'A board backed by Jira shows its sprints. Native sprints are only for boards with no tracker (D5).', 'Mirrored from Jira, read-only', 'board')], 'org'),
435
437
  sect('Wispr Flow', [list([w ? extRow('notes.wispr', swBtn(w.wispr !== false, EXT['notes.wispr'][2], () => notesSwitch({ wispr: w.wispr === false }, 'notes.wispr', { wispr: w.wispr !== false }), { disabled: !!override('notes.wispr') }), { scope: 'computer' }) : null, storeRow('conn.wispr.wait')]),
@@ -691,7 +693,229 @@ export function mountSettings(host, ctx = {}) {
691
693
  return [sect('PMOS app', [list([appRow])], { ic: 'cloud', attrs: { 'data-c3set-app': '' } }), sect('Kit in each workspace', [list(rows)], { ic: 'repo', count: rows.length }), groupOf('updates', 'When', 'cloud')];
692
694
  }
693
695
 
694
- const BUILD = { about: aboutSec, workspaces: workspacesSec, boards: boardsSec, connectors: () => [...connectorsSec(), finderBlock(M.ws)], recording: recordingSec, time: timeSec, sharing: sharingSec, access: accessSec, sessions: sessionsSec, privacy: privacySec, appearance: appearanceSec, updates: updatesSec };
696
+ /* ── Data refresh: every source PMOS reads or writes on a schedule (PRD data-refresh-settings R9).
697
+ GET /api/refresh?ws · POST /api/refresh/source, /rules, /run, /run-all · GET /api/refresh/log, /log.csv. One home: Connectors links here. ── */
698
+ const RF_GROUPS = [['tools', 'From your tools', 'org', 'Read through your Claude session. PMOS holds no keys for these.'], ['pmos', 'Written by PMOS', 'spark', 'Background runs that write cards, documents and proposals.'], ['change', 'Kept in step on every change', 'repo', 'No schedule: these follow your files as they change.']];
699
+ const RF_ST = { recent: ['Recent', 'ok'], old: ['Old', 'warn'], failed: ['Failed', 'warn'], running: ['Refreshing now', 'acc'], waiting: ['Waiting', 'acc'], off: ['Off', ''], never: ['Not yet', ''] };
700
+ const RF_KIND = { manual: 'When I press', every: 'Every few minutes', daily: 'Daily', weekdays: 'Working days', weekly: 'Weekly' };
701
+ const RF_DAYS = [['Mon', 'Monday'], ['Tue', 'Tuesday'], ['Wed', 'Wednesday'], ['Thu', 'Thursday'], ['Fri', 'Friday'], ['Sat', 'Saturday'], ['Sun', 'Sunday']];
702
+ const RF_EVERY = { 10: '10 min', 15: '15 min', 30: '30 min', 60: 'hour', 120: '2 hours', 240: '4 hours' };
703
+ const RF_HOURS = []; for (let h = 5; h <= 22; h++) for (const m of ['00', '30']) RF_HOURS.push(String(h).padStart(2, '0') + ':' + m);
704
+ const RF_DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const RF_MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
705
+ const pad2 = (n) => String(n).padStart(2, '0');
706
+ /** Local time: HH:MM for today, "Mon 06:30" within a week, "Sun 11 Oct 06:30" beyond. */
707
+ function fmtAt(iso) {
708
+ const d = iso ? new Date(iso) : null; if (!d || isNaN(d)) return '';
709
+ const t = pad2(d.getHours()) + ':' + pad2(d.getMinutes()); const n = new Date();
710
+ if (d.toDateString() === n.toDateString()) return t;
711
+ if (Math.abs(d - n) < 6 * 864e5) return RF_DOW[d.getDay()] + ' ' + t;
712
+ return RF_DOW[d.getDay()] + ' ' + d.getDate() + ' ' + RF_MON[d.getMonth()] + ' ' + t;
713
+ }
714
+ const fmtTook = (ms) => { if (ms == null) return ''; const s = Math.max(1, Math.round(ms / 1000)); return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min' + (s % 60 ? ' ' + (s % 60) + ' s' : ''); };
715
+ const rfRows = () => (M.rf && M.rf.rows) || [];
716
+ const rfRow = (id) => rfRows().find((x) => x.id === id);
717
+ const rfName = (id) => { const x = rfRow(id); return x ? x.label : id; };
718
+ const rfNames = (ids) => (ids || []).map(rfName).join(', ');
719
+ const needsLook = (r) => r.on && (r.state === 'failed' || r.state === 'old');
720
+ const sig = (s) => JSON.stringify(s);
721
+
722
+ async function loadRf(force) {
723
+ const [r, lg] = await Promise.all([pmGet('/api/refresh?' + qs({ ws: M.ws })), pmGet('/api/refresh/log?' + qs({ source: M.logSource, result: M.logResult, limit: 50 }))]);
724
+ const next = r && r.ok ? r : { error: (r && r.error) || 'The refresh schedule could not be read.' };
725
+ const log = lg && lg.ok ? { total: lg.total, runs: lg.runs || [] } : { error: (lg && lg.error) || 'The log could not be read.' };
726
+ const s = sig([next, log]); const same = s === M.rfSig; M.rfSig = s; M.rf = next; M.rfLog = log;
727
+ const a = document.activeElement;
728
+ const busy = !force && (M.rfEdit || M.rfAsk || (a && body.contains(a) && a.tagName === 'SELECT'));
729
+ if (force || (!same && !busy)) draw();
730
+ }
731
+ /* Poll while the section shows; stop when another section, a search or another page is on screen. */
732
+ let rfTimer = null;
733
+ function rfWatch() {
734
+ const on = M.sec === 'refresh' && !M.q;
735
+ if (rfTimer && (!on || !root.isConnected)) { clearInterval(rfTimer); rfTimer = null; }
736
+ if (on && !rfTimer) rfTimer = setInterval(() => { if (M.sec === 'refresh' && !M.q && root.isConnected && root.offsetParent) loadRf(false); }, 15000);
737
+ if (on && !M.rf && !M.rfLoading) { M.rfLoading = true; loadRf(true).finally(() => { M.rfLoading = false; }); }
738
+ }
739
+ const rfAfter = async () => { await loadRf(true); };
740
+
741
+ async function rfSetOn(r, on) {
742
+ M.rfAsk = null;
743
+ const res = await pmPost('/api/refresh/source', { id: r.id, on });
744
+ if (!res || !res.ok) { M.rfErr[r.id] = (res && res.error) || 'Not saved.'; draw(); return; }
745
+ delete M.rfErr[r.id]; await rfAfter();
746
+ toast((on ? 'Turned on: ' : 'Turned off: ') + r.label, async () => { await pmPost('/api/refresh/source', { id: r.id, on: !on }); await rfAfter(); });
747
+ }
748
+ async function rfSaveSchedule(r, sch, lead) {
749
+ const old = r.schedule;
750
+ const res = await pmPost('/api/refresh/source', { id: r.id, schedule: sch });
751
+ if (!res || !res.ok) { const msg = (res && res.error) || 'Not saved.'; if (M.rfEdit && M.rfEdit.id === r.id) M.rfEdit.err = msg; else M.rfErr[r.id] = msg; draw(); return false; }
752
+ M.rfEdit = null; delete M.rfErr[r.id]; await rfAfter();
753
+ const nr = rfRow(r.id);
754
+ toast(lead + ': ' + r.label + (nr ? ', ' + nr.schedule_text : ''), old ? async () => { await pmPost('/api/refresh/source', { id: r.id, schedule: old }); await rfAfter(); } : undefined);
755
+ return true;
756
+ }
757
+ async function rfRunNow(r, withInputs) {
758
+ const body = { id: r.id, ws: M.ws }; if (withInputs) body.with_inputs = true;
759
+ const res = await pmPost('/api/refresh/run', body);
760
+ if (!res || !res.ok) { toast((res && res.error) || r.label + ' did not start.'); await rfAfter(); return; }
761
+ await rfAfter();
762
+ toast(res.waiting && res.waiting.length ? r.label + ' starts when ' + rfNames(res.waiting) + ' has finished.' : 'Refreshing ' + r.label + '. You can leave this page.');
763
+ }
764
+ async function rfRunAll() {
765
+ M.rfAllBusy = true; draw();
766
+ const res = await pmPost('/api/refresh/run-all', { ws: M.ws });
767
+ M.rfAllBusy = false; await rfAfter();
768
+ if (!res || !res.ok) { toast((res && res.error) || 'Refresh all did not start.'); return; }
769
+ const bad = (res.failed || []).length;
770
+ toast('Refreshing ' + (res.started || []).length + ' sources.' + (bad ? ' ' + bad + ' did not start: ' + res.failed.map((f) => rfName(f.id) + ' (' + f.error + ')').join(', ') + '.' : ''));
771
+ }
772
+ async function rfSaveRule(key, value, label) {
773
+ const old = M.rf.rules[key];
774
+ const res = await pmPost('/api/refresh/rules', { [key]: value });
775
+ if (!res || !res.ok) { M.err['rf.' + key] = (res && res.error) || 'Not saved.'; draw(); return; }
776
+ delete M.err['rf.' + key]; if (res.rules) M.rf.rules = res.rules; await rfAfter();
777
+ toast('Saved: ' + label, async () => { await pmPost('/api/refresh/rules', { [key]: old }); await rfAfter(); });
778
+ }
779
+
780
+ /* the editor's draft is cleaned to the shape the API takes */
781
+ const cleanSchedule = (d) => d.kind === 'manual' ? { kind: 'manual' }
782
+ : d.kind === 'every' ? { kind: 'every', every: String(d.every), work: !!d.work }
783
+ : d.kind === 'weekly' ? { kind: 'weekly', day: d.day, times: [d.times[0]] } : { kind: d.kind, times: d.times.slice() };
784
+ function openEditor(r) {
785
+ const s = r.schedule || r.default || { kind: r.kinds[0] };
786
+ M.rfEdit = { id: r.id, err: '', d: { kind: s.kind, times: (s.times || []).slice(), day: s.day || 'Mon', every: String(s.every || (r.every || [])[0] || '60'), work: !!s.work } };
787
+ draw();
788
+ }
789
+ function setKind(r, k) {
790
+ const d = M.rfEdit.d; d.kind = k;
791
+ if (k === 'every' && !(r.every || []).includes(d.every)) d.every = (r.every || ['60'])[0];
792
+ if (['daily', 'weekdays', 'weekly'].includes(k) && !d.times.length) d.times = ['09:00'];
793
+ if (k === 'weekly') d.times = d.times.slice(0, 1);
794
+ M.rfEdit.err = ''; draw();
795
+ }
796
+ const timeOpts = (v) => (RF_HOURS.includes(v) ? RF_HOURS : [...RF_HOURS, v].sort()).map((h) => [h, h]);
797
+
798
+ /** One run of the log: when, which source, the result, how long and what it got. */
799
+ function runLine(run, withSource) {
800
+ const res = run.result; const bad = res === 'failed' || res === 'interrupted';
801
+ const tone = res === 'done' ? 'ok' : bad ? 'warn' : res === 'skipped' ? '' : 'acc';
802
+ const label = res === 'done' ? 'Done' : res === 'failed' ? 'Failed' : res === 'skipped' ? 'Skipped' : res === 'interrupted' ? 'Interrupted' : 'Running';
803
+ return el('li', { class: 'nrow', 'data-c3set-rfrun': run.run || '' },
804
+ el('span', { class: cx('ki', bad && 't-warn') }, c3ic(bad ? 'alert' : !res ? 'wait' : 'check', 'sm')),
805
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, (withSource ? rfName(run.source) + ' · ' : '') + fmtAt(run.start_at)),
806
+ el('div', { class: 'chips' }, tag(label, tone), run.took_ms != null ? chip('took ' + fmtTook(run.took_ms)) : null, run.trigger ? chip(run.trigger) : null, run.got ? chip(run.got) : null, run.note ? chip(run.note) : null),
807
+ run.error ? fold('alert', run.error, 'w') : null));
808
+ }
809
+
810
+ function rfEditor(r) {
811
+ const E = M.rfEdit; const d = E.d; const win = (M.rf && M.rf.window) || { start: '09:00', end: '18:00' };
812
+ const rows = [];
813
+ if (r.kinds.length > 1) rows.push(frame({ key: 'rf.kind', label: 'How often', control: seg('How often', r.kinds.map((k) => [k, RF_KIND[k] || k]), d.kind, (k) => setKind(r, k)) }));
814
+ if (d.kind === 'every') rows.push(frame({ key: 'rf.every', label: 'Every', forId: 'c3set-rf-every', control: select('c3set-rf-every', 'How often', (r.every || []).map((v) => [v, RF_EVERY[v] || v + ' min']), d.every, (v) => { d.every = v; draw(); }) }));
815
+ if (d.kind === 'daily' || d.kind === 'weekdays') {
816
+ rows.push(frame({ key: 'rf.times', label: 'At', help: d.kind === 'weekdays' ? 'Monday to Friday.' : '',
817
+ control: [...d.times.flatMap((t, i) => [select('c3set-rf-time-' + i, 'Time ' + (i + 1), timeOpts(t), t, (v) => { d.times[i] = v; d.times = [...new Set(d.times)].sort(); draw(); }),
818
+ d.times.length > 1 ? bt({ type: 'button', class: 'ib sm', 'aria-label': 'Remove ' + t, onclick: () => { d.times.splice(i, 1); draw(); } }, c3ic('x', 'sm')) : null]),
819
+ d.times.length < 4 ? btn('Add a time', { variant: 'ghost', ic: 'plus', attrs: { 'data-c3set-rf-addtime': '' }, onclick: () => { const last = d.times[d.times.length - 1]; const h = Math.min(22, +last.slice(0, 2) + 4); let t = pad2(h) + ':' + last.slice(3); for (const o of RF_HOURS) { if (!d.times.includes(t)) break; t = o; } if (!d.times.includes(t)) d.times = [...d.times, t].sort(); draw(); } }) : null] }));
820
+ }
821
+ if (d.kind === 'weekly') rows.push(frame({ key: 'rf.day', label: 'On', control: [select('c3set-rf-day', 'Day', RF_DAYS, d.day, (v) => { d.day = v; draw(); }), select('c3set-rf-time-0', 'Time', timeOpts(d.times[0]), d.times[0], (v) => { d.times = [v]; draw(); })] }));
822
+ if (d.kind === 'every') rows.push(frame({ key: 'rf.work', label: 'Only in working hours', help: [win.start + ' to ' + win.end + ', set in ', secLink('time', 'Day and time'), '. Outside them this waits for the next slot.'],
823
+ control: swBtn(d.work, 'Only in working hours', () => { d.work = !d.work; draw(); }) }));
824
+ const hist = (r.runs || []).slice(0, 5).map((x) => runLine(x, false));
825
+ const isDef = !r.default || sig(r.schedule) === sig(r.default);
826
+ return el('div', { class: 'rf-ed', 'data-c3set-rf-editor': r.id },
827
+ list(rows),
828
+ el('div', { class: 'mk-lbl dim st-help' }, 'Last runs'),
829
+ hist.length ? list(hist) : help('Nothing has run yet.'),
830
+ E.err ? el('div', { class: 'fold w', role: 'alert', 'data-c3set-rf-err': '' }, c3ic('alert', 'sm'), el('span', null, E.err)) : null,
831
+ el('div', { class: 'iacts' }, btn('Save', { variant: 'primary', attrs: { 'data-c3set-rf-save': r.id }, onclick: () => rfSaveSchedule(r, cleanSchedule(d), 'Saved') }),
832
+ btn('Cancel', { variant: 'ghost', onclick: () => { M.rfEdit = null; draw(); } }),
833
+ isDef ? null : btn('Back to default', { variant: 'ghost', ic: 'undo', attrs: { 'data-c3set-rf-default': r.id }, onclick: () => rfSaveSchedule(r, r.default, 'Back to the default') })));
834
+ }
835
+
836
+ function rfSourceRow(r) {
837
+ const [lab, tone] = RF_ST[r.state] || ['', ''];
838
+ const open = !!(M.rfEdit && M.rfEdit.id === r.id); const asking = M.rfAsk === r.id; const running = r.state === 'running';
839
+ const l = r.last;
840
+ const how = r.how === 'tool' ? 'Your Claude session reads it' + (profileNow().id ? ' through profile ' + profileNow().id : '') : r.how === 'bg' ? 'A background Claude run writes it' : 'Code on this computer, no model';
841
+ const lastRes = !l ? 'It has not run yet.' : running || !l.result ? 'It is running now.' : l.result === 'failed' ? 'Last result: failed.' : l.result === 'skipped' ? 'The last run was skipped' + (l.note ? ': ' + l.note : '') + '.'
842
+ : l.result === 'interrupted' ? 'The last run was interrupted.' : 'Last result: ' + (l.got || 'done') + (l.took_ms != null ? ', took ' + fmtTook(l.took_ms) : '') + '.';
843
+ const lastChip = running && l ? 'Started ' + fmtAt(l.start_at) : l ? 'Last refreshed ' + fmtAt(l.at || l.start_at) : 'Never refreshed';
844
+ const nextChip = r.state === 'waiting' ? 'Waiting for ' + rfNames(r.waiting_for) : r.on && r.next_at ? 'Next ' + fmtAt(r.next_at) : null;
845
+ const relation = [r.uses.length ? 'Uses: ' + rfNames(r.uses) + '.' : null, r.used_by.length ? 'Used by: ' + rfNames(r.used_by) + '.' : null].filter(Boolean).join(' ');
846
+ const ask = asking ? el('div', { class: 'fold w', role: 'alert', 'data-c3set-rf-ask': r.id }, c3ic('alert', 'sm'),
847
+ el('span', null, 'If you turn off ' + r.label + ': ' + r.impact + (r.used_by.length ? ' These use it: ' + rfNames(r.used_by) + '.' : ''))) : null;
848
+ const nb = [
849
+ el('span', { class: 'tt' }, el('span', { class: cx('ki', r.on && r.state === 'failed' && 't-warn') }, c3ic(r.ic, 'sm')), r.label),
850
+ el('div', { class: 'chips' }, tag(lab, tone), chip(lastChip), chip(r.schedule_text), nextChip ? chip(nextChip) : null),
851
+ help(r.what + ' ' + how + '. ' + lastRes),
852
+ relation ? help(relation) : null,
853
+ r.set_in ? help('Set in ' + r.set_in + '.') : null,
854
+ r.on && r.state === 'failed' ? fold('alert', (l && l.error) || 'The last run failed.', 'w', r.group === 'tools' ? btn('Open Connectors', { variant: 'ghost', ic: 'next', onclick: () => goSec('connectors') }) : null) : null,
855
+ r.on && r.state === 'old' && l && l.note ? fold('clock', l.note) : null,
856
+ r.built_on.length ? el('div', { class: 'fold', 'data-c3set-rf-builton': r.id }, c3ic('clock', 'sm'),
857
+ el('span', null, 'This builds on older data: ' + r.built_on.map((b) => b.label + (b.state && b.state !== 'recent' ? ' (' + (RF_ST[b.state] || [b.state])[0].toLowerCase() + ')' : '')).join(', ') + '.'),
858
+ btn('Refresh inputs first', { variant: 'ghost', ic: 'undo', disabled: !r.on || running, attrs: { 'data-c3set-rf-inputs': r.id }, onclick: () => rfRunNow(r, true) })) : null,
859
+ ask,
860
+ M.rfErr[r.id] ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), el('span', null, M.rfErr[r.id])) : null,
861
+ open ? rfEditor(r) : null];
862
+ const acts = asking
863
+ ? [btn('Turn off', { variant: 'primary', attrs: { 'data-c3set-rf-off-go': r.id }, onclick: () => rfSetOn(r, false) }), btn('Keep it on', { variant: 'ghost', onclick: () => { M.rfAsk = null; draw(); } })]
864
+ : [swBtn(r.on, (r.on ? 'Turn off ' : 'Turn on ') + r.label, () => { if (r.on) { M.rfAsk = r.id; draw(); } else rfSetOn(r, true); }, { 'data-c3set-rf-on': r.id }),
865
+ btn(running ? 'Refreshing' : 'Refresh now', { ic: 'undo', disabled: !r.on || running, attrs: { 'data-c3set-rf-now': r.id }, onclick: () => rfRunNow(r, false) }),
866
+ r.kinds.length ? btn(open ? 'Close' : 'Schedule', { variant: 'ghost', ic: open ? 'fold' : 'clock', attrs: { 'data-c3set-rf-open': r.id }, onclick: () => { if (open) M.rfEdit = null; else openEditor(r); draw(); } }) : null];
867
+ return el('li', { class: 'nrow st-row rf-row', 'data-c3set-rf': r.id }, el('div', { class: 'nb' }, ...nb.filter(Boolean)), el('div', { class: 'acts' }, ...acts.filter(Boolean)));
868
+ }
869
+
870
+ function rfRules() {
871
+ const R = M.rf.rules; const win = M.rf.window; const q = M.rf.quiet;
872
+ const rule = (key, label, h, control) => frame({ key: 'rf.' + key, label, help: h, scope: 'computer', control, err: M.err['rf.' + key] });
873
+ return sect('Rules for every refresh', [list([
874
+ rule('catchup', 'Catch up after PMOS was closed', 'When PMOS starts and a refresh was missed while it was closed, it runs once right away instead of waiting for the next slot.', swBtn(R.catchup, 'Catch up after PMOS was closed', () => rfSaveRule('catchup', !R.catchup, 'catch up'))),
875
+ rule('retry', 'When a refresh fails', 'Failures show on the source here and in the line under the page title.', select('c3set-rf-retry', 'When a refresh fails', [['15', 'Try again once after 15 min'], ['60', 'Try again once after an hour'], ['no', 'Wait for the next slot']], R.retry, (v) => rfSaveRule('retry', v, 'when a refresh fails'))),
876
+ rule('tell', 'Tell me about a failed refresh', 'A card on the Briefing names the source and what to do.', seg('Tell me about a failed refresh', [['brief', 'On the Briefing'], ['here', 'Only here']], R.tell, (v) => rfSaveRule('tell', v, 'tell me about a failed refresh'))),
877
+ rule('max', 'Background runs at once', 'Runs that use Claude wait in line beyond this number.', select('c3set-rf-max', 'Background runs at once', [['1', '1'], ['2', '2'], ['3', '3'], ['5', '5']], R.max, (v) => rfSaveRule('max', v, 'background runs at once'))),
878
+ rule('keep', 'Keep the log for', 'Older lines of the refresh log are deleted. The export holds every line that is kept.', select('c3set-rf-keep', 'Keep the log for', [['30', '30 days'], ['90', '90 days'], ['365', 'A year'], ['forever', 'Forever']], R.keep, (v) => rfSaveRule('keep', v, 'keep the log'))),
879
+ frame({ key: 'rf.quiet', label: 'Quiet hours', scope: 'computer', help: 'No scheduled refresh starts in quiet hours. A refresh you press still runs. Quiet hours are set in the proactive settings, not here.', control: chip(q ? q.start + ' to ' + q.end : 'None') }),
880
+ frame({ key: 'rf.window', label: 'Background working hours', scope: 'computer', help: ['Background runs that follow working hours use these. They are set in ', secLink('time', 'Day and time'), '.'], control: chip(win ? win.start + ' to ' + win.end : '') })])], { ic: 'clock', attrs: { 'data-c3set-rf-rules': '' } });
881
+ }
882
+
883
+ function rfLogCard() {
884
+ const L = M.rfLog || {};
885
+ const f = { source: M.logSource, result: M.logResult };
886
+ const href = '/api/refresh/log.csv' + (qs(f) ? '?' + qs(f) : '');
887
+ const setF = (k, v) => { if (k === 'source') M.logSource = v; else M.logResult = v; loadRf(true); };
888
+ const filters = el('div', { class: 'iacts' },
889
+ select('c3set-rf-log-source', 'Source', [['', 'All sources'], ...rfRows().map((x) => [x.id, x.label])], M.logSource, (v) => setF('source', v)),
890
+ select('c3set-rf-log-result', 'Result', [['', 'Any result'], ['done', 'Done'], ['failed', 'Failed'], ['skipped', 'Skipped'], ['interrupted', 'Interrupted']], M.logResult, (v) => setF('result', v)),
891
+ el('a', { class: 'chip2 lk', href, download: 'pmos-refresh-log.csv', 'data-c3set-rf-export': '' }, c3ic('out', 'sm'), 'Export'));
892
+ const runs = L.runs || [];
893
+ return sect('Refresh log', [filters, L.error ? fold('alert', L.error, 'w') : runs.length ? list(runs.map((x) => runLine(x, true))) : fold(null, M.logSource || M.logResult ? 'No run matches.' : 'Nothing has been refreshed yet.'),
894
+ L.total > runs.length ? help('Showing the newest ' + runs.length + ' of ' + L.total + '. Export has every line.') : null], { ic: 'clock', count: L.total != null ? L.total : null, attrs: { 'data-c3set-rf-log': '' } });
895
+ }
896
+
897
+ function refreshSec() {
898
+ const D = M.rf;
899
+ if (!D) return [fold(null, 'Reading the refresh schedule.')];
900
+ if (D.error) return [fold('alert', D.error, 'w')];
901
+ const rows = D.rows || [];
902
+ const on = rows.filter((r) => r.on); const bad = rows.filter(needsLook); const run = rows.filter((r) => r.state === 'running');
903
+ const next = on.filter((r) => r.next_at).sort((a, b) => new Date(a.next_at) - new Date(b.next_at))[0];
904
+ const sum = on.length + ' of ' + rows.length + ' on · ' + on.filter((r) => r.state === 'recent').length + ' recent' + (bad.length ? ' · ' + bad.length + ' need a look' : '') + (run.length ? ' · ' + run.length + ' refreshing now' : '');
905
+ const top = sect('Overview', [el('div', { class: 'st-now', 'data-c3set-rf-sum': '' }, sum),
906
+ help((next ? 'Next: ' + next.label + ' at ' + fmtAt(next.next_at) + '. ' : '') + 'PMOS refreshes only while it is running on this computer. The same states show in the line under the page title.'),
907
+ el('div', { class: 'iacts' }, seg('Show', [['all', 'All'], ['needs', 'Needs a look' + (bad.length ? ' (' + bad.length + ')' : '')], ['off', 'Off']], M.rfFilter, (v) => { M.rfFilter = v; draw(); }),
908
+ btn(M.rfAllBusy ? 'Starting' : 'Refresh all now', { ic: 'undo', disabled: M.rfAllBusy || !on.length, attrs: { 'data-c3set-rf-all': '' }, onclick: rfRunAll }))],
909
+ { ic: 'undo', right: tag(bad.length ? bad.length + ' need a look' : 'All recent', bad.length ? 'warn' : 'ok') });
910
+ const pick = (r) => M.rfFilter === 'all' || (M.rfFilter === 'needs' && needsLook(r)) || (M.rfFilter === 'off' && !r.on);
911
+ const cards = RF_GROUPS.map(([g, t, ic, intro]) => {
912
+ const gr = rows.filter((r) => r.group === g && pick(r));
913
+ return gr.length ? sect(t, [help(intro), list(gr.map(rfSourceRow))], { ic, count: gr.length, attrs: { 'data-c3set-rf-card': g } }) : null;
914
+ });
915
+ return [top, ...(cards.some(Boolean) ? cards : [fold(null, M.rfFilter === 'off' ? 'Every source is on.' : 'Nothing needs a look. Every source refreshed on time.')]), rfRules(), rfLogCard()];
916
+ }
917
+
918
+ const BUILD = { about: aboutSec, workspaces: workspacesSec, boards: boardsSec, connectors: () => [...connectorsSec(), finderBlock(M.ws)], refresh: refreshSec, recording: recordingSec, time: timeSec, sharing: sharingSec, access: accessSec, sessions: sessionsSec, privacy: privacySec, appearance: appearanceSec, updates: updatesSec };
695
919
 
696
920
  function searchView() {
697
921
  const hits = rowsMatching(M.q);
@@ -744,7 +968,7 @@ export function mountSettings(host, ctx = {}) {
744
968
  // never rebuild under a field the PM is typing in
745
969
  const a = document.activeElement;
746
970
  if (a && body.contains(a) && ((a.tagName === 'INPUT' && a.type === 'text') || a.tagName === 'TEXTAREA')) { if (!draw.pending) { draw.pending = true; a.addEventListener('blur', drawAfterBlur, { once: true }); } return; }
747
- paintNav();
971
+ paintNav(); rfWatch();
748
972
  if (M.q) { body.replaceChildren(...searchView().filter(Boolean)); return; }
749
973
  const [id, title, ic, intro] = SECTIONS.find((s) => s[0] === M.sec);
750
974
  const needsStore = id !== 'about';
@@ -173,6 +173,17 @@
173
173
  "B1 with nothing stored, status says absent",
174
174
  "B5 status never carries the key or its length"
175
175
  ]
176
+ },
177
+ {
178
+ "id": "STD-REFRESH",
179
+ "file": "workspace/proactive.test.js",
180
+ "run": "data-refresh-settings",
181
+ "ref": "planning/prd/data-refresh-settings-prd.md R3 (the PM said Build on 2026-10-14)",
182
+ "reason": "R3 moves the morning brief's schedule from schedules.json to Settings › Data refresh. The brief set no longer writes a schedule; it turns the brief source on, daily at 06:45. H8 now asserts that through GET /api/refresh, and I7 accepts the CLI's new 'already on in Settings, Data refresh' line.",
183
+ "names": [
184
+ "H8 one press creates the daily 06:45 morning-brief schedule once; a second press adds nothing",
185
+ "I7 `brief schedule` reports that the schedule already exists"
186
+ ]
176
187
  }
177
188
  ],
178
189
  "entries": [