create-pmos 0.54.0 → 0.55.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/guide/index.md +1 -0
- package/workspace/guide/layout.md +1 -0
- package/workspace/guide/proactive-engine.md +72 -9
- package/workspace/guide/today.md +25 -2
- package/workspace/lib/brief.js +244 -8
- package/workspace/lib/day-run.js +297 -0
- package/workspace/lib/goals.js +25 -3
- package/workspace/lib/proactive-cli.js +119 -6
- package/workspace/lib/proactive.js +53 -7
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +13 -1
- package/workspace/public/pm/branches-sheet.js +56 -0
- package/workspace/public/pm/components.css +134 -23
- package/workspace/public/pm/dest-today.js +10 -8
- package/workspace/public/pm/frame-copy.js +8 -0
- package/workspace/public/pm/today-copy.js +182 -0
- package/workspace/public/pm/today-day.js +760 -0
- package/workspace/public/pm/today-goals.js +96 -161
- package/workspace/replaced-tests.json +10 -0
- package/workspace/server.js +190 -9
- package/workspace/skills/day-check.skill +56 -0
- package/workspace/skills/day-task.skill +47 -0
- package/workspace/skills/morning-brief.skill +45 -10
- package/workspace/test-files.json +2 -0
|
@@ -1,206 +1,141 @@
|
|
|
1
|
-
/* pm/today-goals.js — the goals for today
|
|
1
|
+
/* pm/today-goals.js — the goals for today inside the day section (initiative today-goals, PRD G5, G6 and G16).
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* goalRows(ctx) → the left column's goal rows: number, text, jobs done, a small bar
|
|
4
|
+
* goalAdd(ctx) → the add input under them ("Add a goal and press Enter")
|
|
5
|
+
* goalDetail(ctx, goalId) → the right column's detail of one goal: its meetings, its jobs, the steps found, and the
|
|
6
|
+
* presses done, drop, reopen and remove; on an agent step that names an initiative, Prepare
|
|
7
|
+
* then Queue it (the shipped background prepare: one approval is one prompt hash)
|
|
8
|
+
* addGoal(ctx, text, ini) → POST the add; true when the goal is on the list
|
|
9
|
+
* goalsSig() → a string that changes whenever what this module draws changes on its own
|
|
5
10
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* morning-brief schedule) and, on an agent step that names an initiative, Prepare then Queue it (the shipped background
|
|
9
|
-
* prepare: one approval is one prompt hash).
|
|
10
|
-
*
|
|
11
|
-
* FIRST PAINT. Folded, the section is one control (its toggle) and a list of the goals as text, so Today keeps inside its
|
|
12
|
-
* control budget (first-paint-today.test.js). The input and the buttons are drawn only when the PM opens it.
|
|
11
|
+
* `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
|
|
12
|
+
* The server joins the goals to the brief and Today by goal id or initiative (lib/goals.js planDay). Nothing here guesses.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { el } from './dom.js';
|
|
16
|
-
import { button, field, badge, toast } from './primitives.js';
|
|
17
|
-
import {
|
|
18
|
-
import { FIXTURE } from './state.js';
|
|
16
|
+
import { button, field, badge, toast, row, meter } from './primitives.js';
|
|
17
|
+
import { pmPost } from './http.js';
|
|
19
18
|
import { COPY } from './today-copy.js';
|
|
20
19
|
|
|
21
20
|
const G = COPY.goals;
|
|
22
|
-
const
|
|
23
|
-
const cache = new Map(); // ws -> { data, error, at }
|
|
24
|
-
let open = false;
|
|
21
|
+
const D = COPY.day;
|
|
25
22
|
let draft = '';
|
|
26
23
|
const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
|
|
27
|
-
let rerender = () => {};
|
|
28
|
-
let timer = null;
|
|
29
|
-
|
|
30
|
-
async function load(ws) {
|
|
31
|
-
const out = await pmGet('/api/proactive/goals' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
|
|
32
|
-
const prev = cache.get(ws);
|
|
33
|
-
const next = out && out.ok ? { data: out, error: null, at: Date.now() } : { data: prev ? prev.data : null, error: (out && out.error) || G.unreached, at: Date.now() };
|
|
34
|
-
cache.set(ws, next);
|
|
35
|
-
if (!prev || JSON.stringify(prev.data) !== JSON.stringify(next.data) || prev.error !== next.error) rerender();
|
|
36
|
-
}
|
|
37
24
|
|
|
38
|
-
function
|
|
39
|
-
if (!cache.has(ws)) { cache.set(ws, { data: null, error: null, at: 0 }); load(ws); }
|
|
40
|
-
if (!timer && typeof window !== 'undefined') {
|
|
41
|
-
timer = setInterval(() => { for (const w of cache.keys()) load(w); }, POLL_MS);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function press(ws, body) {
|
|
25
|
+
async function press(ctx, body) {
|
|
46
26
|
const r = await pmPost('/api/proactive/goals', body);
|
|
47
27
|
if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return false; }
|
|
48
|
-
await
|
|
28
|
+
await ctx.reload();
|
|
49
29
|
return true;
|
|
50
30
|
}
|
|
51
31
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
32
|
+
/** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. */
|
|
33
|
+
export function addGoal(ctx, text, ini) {
|
|
34
|
+
const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
|
|
35
|
+
if (ini) body.initiative_id = ini;
|
|
36
|
+
return press(ctx, body);
|
|
37
|
+
}
|
|
55
38
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
39
|
+
/** The jobs of a goal: the day model's jobs that name it. */
|
|
40
|
+
const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
|
|
41
|
+
|
|
42
|
+
export function goalRows(ctx) {
|
|
43
|
+
const { m } = ctx;
|
|
44
|
+
if (!m.goals.length) return null;
|
|
45
|
+
return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g, i) => {
|
|
46
|
+
const js = jobsOf(m, g.id);
|
|
47
|
+
const done = js.filter((j) => j.group === 'done').length;
|
|
48
|
+
const need = js.filter((j) => j.group === 'needs').length;
|
|
49
|
+
const closed = g.status !== 'open';
|
|
50
|
+
const sub = closed ? (G.coverage[g.coverage] || g.status) : js.length ? D.goalJobs(done, js.length) + (need ? ' · ' + D.goalNeed(need) : '') : (G.coverage[g.coverage] || D.goalNone);
|
|
51
|
+
const cur = ctx.cur('goal', g.id);
|
|
52
|
+
return el('li', null, row({
|
|
53
|
+
title: g.text, meta: [sub], done: closed, onOpen: () => ctx.select('goal', g.id),
|
|
54
|
+
lead: [el('span', { class: 'td-day__lead' }, String(i + 1))],
|
|
55
|
+
trail: [meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
|
|
56
|
+
attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
|
|
57
|
+
openAttrs: { 'aria-current': cur },
|
|
58
|
+
}));
|
|
59
|
+
}));
|
|
75
60
|
}
|
|
76
61
|
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
export function goalAdd(ctx) {
|
|
63
|
+
const { m } = ctx;
|
|
64
|
+
const max = m.maxGoals;
|
|
65
|
+
if (m.goals.length >= max) return el('p', { class: 'td-day__note' }, G.full(max));
|
|
66
|
+
const f = field({ label: G.inputLabel, value: '', placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', 'data-day-input': 'goal', maxlength: '140' },
|
|
67
|
+
onCommit: async (v) => {
|
|
68
|
+
if (!String(v).trim()) { draft = v; return; }
|
|
69
|
+
// clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
|
|
70
|
+
draft = '';
|
|
71
|
+
if (await addGoal(ctx, v)) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; ctx.rerender(); }
|
|
72
|
+
else { draft = v; ctx.rerender(); }
|
|
73
|
+
} });
|
|
74
|
+
// a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
|
|
75
|
+
f.control.value = draft;
|
|
76
|
+
f.control.addEventListener('input', () => { draft = f.control.value; });
|
|
77
|
+
return el('div', { class: 'td-day__goaladd' }, f);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
async function prepareStep(
|
|
82
|
-
const r = await pmPost('/api/proactive/prepare/plan', { ws, initiative_id: st.initiative_id });
|
|
80
|
+
async function prepareStep(ctx, st) {
|
|
81
|
+
const r = await pmPost('/api/proactive/prepare/plan', { ws: ctx.ws, initiative_id: st.initiative_id });
|
|
83
82
|
if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return; }
|
|
84
83
|
if (r.refused) { toast({ text: G.refused(r.refused.why) }); return; }
|
|
85
|
-
prepared.set(st.ref, { key: r.item.key, branch: r.plan && r.plan.branch, item_id: r.item.id, prompt_sha: r.prompt_sha, initiative_id: st.initiative_id, ws: r.ws || ws });
|
|
86
|
-
rerender();
|
|
84
|
+
prepared.set(st.ref, { key: r.item.key, branch: r.plan && r.plan.branch, item_id: r.item.id, prompt_sha: r.prompt_sha, initiative_id: st.initiative_id, ws: r.ws || ctx.ws });
|
|
85
|
+
ctx.rerender();
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
async function queueStep(ref) {
|
|
88
|
+
async function queueStep(ctx, ref) {
|
|
90
89
|
const p = prepared.get(ref);
|
|
91
90
|
if (!p) return;
|
|
92
91
|
const r = await pmPost('/api/proactive/prepare/queue', { confirm: true, ws: p.ws, initiative_id: p.initiative_id, item_id: p.item_id, prompt_sha: p.prompt_sha });
|
|
93
92
|
prepared.delete(ref);
|
|
94
93
|
toast({ text: r && r.ok ? G.queued(p.key) : G.refused((r && r.error) || G.unreached), tone: r && r.ok ? undefined : 'destructive' });
|
|
95
|
-
rerender();
|
|
94
|
+
ctx.rerender();
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
function stepNode(ws, st, opened) {
|
|
101
|
-
const who = st.owner === 'pm' ? G.you : G.agent;
|
|
97
|
+
function stepNode(ctx, st) {
|
|
102
98
|
const meta = [st.source, st.mention ? G.mention : null, st.effort].filter(Boolean).join(' · ');
|
|
103
99
|
const p = prepared.get(st.ref);
|
|
104
|
-
const acts = !
|
|
100
|
+
const acts = !st.prepare ? null
|
|
105
101
|
: p ? el('span', { class: 'td-goal__prep' }, el('span', { class: 'td-goal__prep-text' }, G.prepareShows(p.key, p.branch)),
|
|
106
|
-
button({ label: G.queue, size: 'sm', attrs: { 'data-goal-queue': st.ref }, onClick: () => queueStep(st.ref) }))
|
|
107
|
-
: button({ label: G.prepare, size: 'sm', variant: 'ghost', attrs: { 'data-goal-prepare': st.ref }, onClick: () => prepareStep(
|
|
102
|
+
button({ label: G.queue, size: 'sm', attrs: { 'data-goal-queue': st.ref }, onClick: () => queueStep(ctx, st.ref) }))
|
|
103
|
+
: button({ label: G.prepare, size: 'sm', variant: 'ghost', attrs: { 'data-goal-prepare': st.ref }, onClick: () => prepareStep(ctx, st) });
|
|
108
104
|
return el('li', { class: 'td-goal__step', 'data-goal-step': st.ref },
|
|
109
|
-
badge({ text:
|
|
105
|
+
badge({ text: st.owner === 'pm' ? G.you : G.agent, tone: st.owner === 'pm' ? 'outline' : 'muted' }),
|
|
110
106
|
el('span', { class: 'td-goal__step-title' }, st.title),
|
|
111
107
|
meta ? el('span', { class: 'td-goal__meta' }, meta) : null,
|
|
112
108
|
acts);
|
|
113
109
|
}
|
|
114
110
|
|
|
115
|
-
|
|
111
|
+
/** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
|
|
112
|
+
export function goalDetail(ctx, id, row) {
|
|
113
|
+
const { m } = ctx;
|
|
114
|
+
const i = m.goals.findIndex((g) => g.id === id);
|
|
115
|
+
if (i < 0) return null;
|
|
116
|
+
const g = m.goals[i];
|
|
116
117
|
const closed = g.status !== 'open';
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
el('div', { class: 'td-
|
|
129
|
-
el('span', { class: 'td-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function stepList(ws, title, steps, attr) {
|
|
137
|
-
if (!steps.length) return null;
|
|
138
|
-
return el('div', { class: 'td-goals__also', 'data-goals': attr },
|
|
139
|
-
el('p', { class: 'td-goals__sub' }, title),
|
|
140
|
-
el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ws, st, true))));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** The section. `ws` is the repository whose Today cards the goals are joined to. */
|
|
144
|
-
export function goalsSection({ ws, setState } = {}) {
|
|
145
|
-
if (FIXTURE) return null;
|
|
146
|
-
// like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
|
|
147
|
-
// cause a whole-page render under whatever the PM is doing elsewhere
|
|
148
|
-
if (setState) rerender = () => { if (document.querySelector('[data-today="goals"]')) setState({}); };
|
|
149
|
-
ensure(ws);
|
|
150
|
-
const c = cache.get(ws) || {};
|
|
151
|
-
const d = c.data;
|
|
152
|
-
const plan = d && d.plan;
|
|
153
|
-
const goals = plan ? plan.goals : [];
|
|
154
|
-
const openN = goals.filter((g) => g.status === 'open').length;
|
|
155
|
-
const toggle = button({ label: G.toggle(openN, goals.length), variant: 'ghost', iconEnd: open ? 'chevron-down' : 'chevron-right',
|
|
156
|
-
attrs: { 'data-goals': 'toggle', 'aria-expanded': open ? 'true' : 'false' }, onClick: () => { open = !open; rerender(); } });
|
|
157
|
-
const list = goals.length ? el('ol', { class: 'td-goals__list', 'data-goals': 'list' }, ...goals.map((g) => goalNode(ws, g, open))) : null;
|
|
158
|
-
const kids = [el('div', { class: 'td-goals__head' }, toggle), list];
|
|
159
|
-
if (c.error) kids.push(el('p', { class: 'td-goals__note', role: 'status' }, G.refused(c.error)));
|
|
160
|
-
if (open) {
|
|
161
|
-
if (!goals.length) kids.push(el('p', { class: 'td-goals__note' }, G.intro));
|
|
162
|
-
const max = (d && d.max_goals) || 7;
|
|
163
|
-
if (goals.length < max) {
|
|
164
|
-
const f = field({ label: G.inputLabel, value: draft, placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', maxlength: '140' },
|
|
165
|
-
onCommit: async (v) => {
|
|
166
|
-
if (!String(v).trim()) { draft = v; return; }
|
|
167
|
-
// clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
|
|
168
|
-
draft = '';
|
|
169
|
-
if (await press(ws, { action: 'add', text: v })) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; rerender(); }
|
|
170
|
-
else { draft = v; rerender(); }
|
|
171
|
-
} });
|
|
172
|
-
f.control.addEventListener('input', () => { draft = f.control.value; }); // a rebuild keeps what is typed
|
|
173
|
-
kids.push(f);
|
|
174
|
-
} else kids.push(el('p', { class: 'td-goals__note' }, G.full(max)));
|
|
175
|
-
const run = runs.get(ws);
|
|
176
|
-
const busy = !!run && run.state === 'busy';
|
|
177
|
-
kids.push(el('div', { class: 'td-goals__bar' },
|
|
178
|
-
button({ label: busy ? G.starting : G.prepareDay, attrs: { 'data-goals': 'prepare-day', title: G.prepareDayHint, 'aria-busy': busy ? 'true' : 'false' },
|
|
179
|
-
disabled: !openN || busy, onClick: () => prepareDay(ws) }),
|
|
180
|
-
run && run.state === 'started' && run.session
|
|
181
|
-
? button({ label: G.openSession, variant: 'ghost', attrs: { 'data-goals': 'open-session' }, onClick: () => openSession(run.session) }) : null));
|
|
182
|
-
if (run && run.state !== 'busy') {
|
|
183
|
-
kids.push(el('p', { class: 'td-goals__run' + (run.state === 'failed' ? ' td-goals__run--failed' : ''), 'data-goals': 'run', 'data-run': run.state,
|
|
184
|
-
role: run.state === 'failed' ? 'alert' : 'status' }, run.state === 'failed' ? G.runFailed(run.error) : G.runStarted(hhmm(run.at))));
|
|
185
|
-
}
|
|
186
|
-
if (plan) {
|
|
187
|
-
const lines = [];
|
|
188
|
-
if (!plan.brief) lines.push(G.noBrief);
|
|
189
|
-
else if (!plan.brief.fresh) lines.push(G.stale(plan.brief.date));
|
|
190
|
-
if (plan.time) {
|
|
191
|
-
lines.push(G.free(plan.time.free_minutes, plan.time.until));
|
|
192
|
-
if (plan.time.next) lines.push(G.comingUp(plan.time.next.title, hhmm(plan.time.next.start)));
|
|
193
|
-
}
|
|
194
|
-
if (plan.not_read && plan.not_read.length) lines.push(G.notRead(plan.not_read.map((x) => x.source).join(', ')));
|
|
195
|
-
if (lines.length) kids.push(el('p', { class: 'td-goals__note', 'data-goals': 'context' }, lines.join(' ')));
|
|
196
|
-
kids.push(stepList(ws, G.also, plan.also || [], 'also'));
|
|
197
|
-
kids.push(stepList(ws, G.worthAsking, plan.worth_asking || [], 'worth-asking'));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return el('section', { class: 'td-goals', 'data-today': 'goals', 'aria-label': G.title }, ...kids.filter(Boolean));
|
|
118
|
+
const js = jobsOf(m, g.id);
|
|
119
|
+
const meets = m.events.filter((e) => e.goal === g.id);
|
|
120
|
+
const ctrls = closed
|
|
121
|
+
? [button({ label: G.reopen, size: 'sm', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ctx, { action: 'reopen', id: g.id }) })]
|
|
122
|
+
: [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ctx, { action: 'done', id: g.id }) }),
|
|
123
|
+
button({ label: G.drop, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'drop' }, onClick: () => press(ctx, { action: 'drop', id: g.id }) })];
|
|
124
|
+
ctrls.push(button({ label: G.remove, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'remove' },
|
|
125
|
+
onClick: async () => { if (await press(ctx, { action: 'remove', id: g.id })) ctx.select(null, null); } }));
|
|
126
|
+
const steps = (g.steps || []).filter((st) => st.from !== 'brief' || st.prepare); // brief items show as jobs; Today cards and preparable steps here
|
|
127
|
+
return [
|
|
128
|
+
el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
|
|
129
|
+
el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
|
|
130
|
+
g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
|
|
131
|
+
js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
|
|
132
|
+
meets.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.meetings), el('ul', { class: 'td-day__rows' }, ...meets.map((e) => row('event', e.id, e.title, e.time)))) : null,
|
|
133
|
+
js.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.jobs), el('ul', { class: 'td-day__rows' }, ...js.map((j) => row('job', j.id, j.title, '', j)))) : null,
|
|
134
|
+
!closed && steps.length ? el('div', { class: 'td-day__rel', 'data-goals': 'steps' }, el('h4', null, D.stepsTitle), el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ctx, st)))) : null,
|
|
135
|
+
el('div', { class: 'td-day__acts' }, ...ctrls),
|
|
136
|
+
];
|
|
201
137
|
}
|
|
202
138
|
|
|
203
|
-
export function goalsSig(
|
|
204
|
-
|
|
205
|
-
return JSON.stringify([open, draft === '', c.data && c.data.plan, c.error, [...prepared.keys()], runs.get(ws) || null]);
|
|
139
|
+
export function goalsSig() {
|
|
140
|
+
return JSON.stringify([draft === '', [...prepared.keys()]]);
|
|
206
141
|
}
|
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
"I6 an unknown brief source is refused with the allowed names"
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
"id": "STG-MAX30",
|
|
45
|
+
"file": "workspace/proactive.test.js",
|
|
46
|
+
"run": "today-goals",
|
|
47
|
+
"ref": "planning/prd/today-goals-prd.md G9 (a brief holds at most 30 items)",
|
|
48
|
+
"reason": "G8 asserted that 13 items are refused with 'at most 12'. today-goals G9 raises the limit to 30, so it now sends 31 items and expects 'at most 30'; the event limit and the field limits are asserted unchanged.",
|
|
49
|
+
"names": [
|
|
50
|
+
"G8 too many items, too many events and an over-long field are refused"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
43
53
|
{
|
|
44
54
|
"id": "S6-D86",
|
|
45
55
|
"file": "workspace/d86-seams.test.js",
|
package/workspace/server.js
CHANGED
|
@@ -95,6 +95,7 @@ const proactiveLib = require('./lib/proactive');
|
|
|
95
95
|
const learnLib = require('./lib/learn');
|
|
96
96
|
const briefLib = require('./lib/brief');
|
|
97
97
|
const goalsLib = require('./lib/goals');
|
|
98
|
+
const dayRunLib = require('./lib/day-run');
|
|
98
99
|
const todayLib = require('./lib/today');
|
|
99
100
|
const prepareQueue = require('./lib/prepare-queue');
|
|
100
101
|
const harness = require('./lib/harness-parse');
|
|
@@ -2325,7 +2326,7 @@ function checkDueSchedules() {
|
|
|
2325
2326
|
// PMOS_TICK_MS exists for the suites (same footing as PMOS_LIVENESS_MS): proactive-prepare.test.js
|
|
2326
2327
|
// used to wait on two real minutes of this clock. Unset, the interval is the 60 seconds it always was.
|
|
2327
2328
|
const TICK_MS = Math.max(200, Number(process.env.PMOS_TICK_MS) || 60000);
|
|
2328
|
-
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
|
|
2329
|
+
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } try { dayTick(); } catch (e) { warnWriteFail('day run tick', e); } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
|
|
2329
2330
|
|
|
2330
2331
|
// ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
|
|
2331
2332
|
/** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
|
|
@@ -3572,6 +3573,103 @@ function prepareHandoff(body) {
|
|
|
3572
3573
|
};
|
|
3573
3574
|
}
|
|
3574
3575
|
|
|
3576
|
+
// ── Today day run (G13, G14, G21, G22; initiative today-goals) ───────────────────────────────────────────────────
|
|
3577
|
+
// The PM's press of "Prepare my day" records a run in goals/<date>.run.json beside the goals file. These helpers are
|
|
3578
|
+
// the only code that writes that file and the only code that starts its sessions: one per task through the `day-task`
|
|
3579
|
+
// skill (mode do, revise or send) and one per check through the `day-check` skill, both through spawnSession as
|
|
3580
|
+
// fireSchedule starts a scheduled agent skill. Every rule (which task, how many at once, when a check is due) is in
|
|
3581
|
+
// lib/day-run.js; this only reads, starts and writes. Nothing here runs for a day the PM did not press.
|
|
3582
|
+
const dayRunFile = (date) => path.join(GOALS_DIR(), date + '.run.json');
|
|
3583
|
+
function readDayRun(date) {
|
|
3584
|
+
let raw = null;
|
|
3585
|
+
try { raw = JSON.parse(fs.readFileSync(dayRunFile(date), 'utf8')); } catch { /* no run that day */ }
|
|
3586
|
+
const run = dayRunLib.readRun(raw, date);
|
|
3587
|
+
if (run && raw && typeof raw.ws === 'string') run.ws = raw.ws; // the repository the PM pressed in; the sessions run there
|
|
3588
|
+
return run;
|
|
3589
|
+
}
|
|
3590
|
+
function writeDayRun(run) {
|
|
3591
|
+
try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(dayRunFile(run.date), JSON.stringify(run) + '\n'); return true; }
|
|
3592
|
+
catch (e) { warnWriteFail('day run (goals/<date>.run.json)', e); return false; }
|
|
3593
|
+
}
|
|
3594
|
+
function briefOfDay(date) {
|
|
3595
|
+
try { return JSON.parse(fs.readFileSync(path.join(STATE, 'briefs', date + '.json'), 'utf8')); } catch { return null; }
|
|
3596
|
+
}
|
|
3597
|
+
const daySessionAlive = (id) => { const s = id && sessions.get(id); return !!s && s.state !== 'exited'; };
|
|
3598
|
+
// The skill's own body is the prompt, as fireSchedule hands it over; the line after it names this run's task and mode.
|
|
3599
|
+
function daySkillPrompt(run, skillId) {
|
|
3600
|
+
const w = getWs(run.ws);
|
|
3601
|
+
const task = taskById(skillId, w ? w.root : null);
|
|
3602
|
+
if (!task || task.kind !== 'agent' || !task.prompt) return { error: `the ${skillId} skill is not installed for this repository` };
|
|
3603
|
+
return { w, task, prompt: skillStore.retargetFiling(task.prompt, filingContract(w)) };
|
|
3604
|
+
}
|
|
3605
|
+
/** Start one task session; a start that fails marks the task failed with the reason. Returns the new run. */
|
|
3606
|
+
function startDayTask(run, t, now) {
|
|
3607
|
+
const sk = daySkillPrompt(run, 'day-task');
|
|
3608
|
+
let s = null;
|
|
3609
|
+
if (!sk.error) {
|
|
3610
|
+
const prompt = `${sk.prompt}\n\nThis run: /day-task ${t.id} ${t.mode} — read the task first with: pmos proactive day show ${t.id}`;
|
|
3611
|
+
s = spawnSession({ name: `${t.title} · day task`, agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
|
|
3612
|
+
modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
|
|
3613
|
+
}
|
|
3614
|
+
const live = s && s.state !== 'exited';
|
|
3615
|
+
const st = dayRunLib.started(run, t.id, live ? s.id : null, now);
|
|
3616
|
+
if (!st.ok) return run;
|
|
3617
|
+
if (live) return st.run;
|
|
3618
|
+
const f = dayRunLib.fail(st.run, t.id, String(sk.error || (s && s.note) || 'the session could not start').slice(0, 240), now);
|
|
3619
|
+
return f.ok ? f.run : st.run;
|
|
3620
|
+
}
|
|
3621
|
+
/** Start what lib/day-run.js says may start now (at most 5 sessions at once). */
|
|
3622
|
+
function startDayTasks(run, now) {
|
|
3623
|
+
for (const t of dayRunLib.toStart(run)) run = startDayTask(run, t, now);
|
|
3624
|
+
return run;
|
|
3625
|
+
}
|
|
3626
|
+
/** Start one check session. @returns {{ok:true, run, session}|{ok:false, error}} */
|
|
3627
|
+
function startDayCheck(run, reason, now) {
|
|
3628
|
+
if (run.checks.session_id && daySessionAlive(run.checks.session_id)) return { ok: false, error: 'a check is already running' };
|
|
3629
|
+
if (run.checks.session_id) run = dayRunLib.sessionEnded(run, run.checks.session_id, now).run;
|
|
3630
|
+
const sk = daySkillPrompt(run, 'day-check');
|
|
3631
|
+
if (sk.error) return { ok: false, error: sk.error };
|
|
3632
|
+
const prompt = `${sk.prompt}\n\nThis run: /day-check ${reason} — the last check was at ${run.checks.last || 'none yet'}; read what changed since ${run.checks.last || run.started_at}.`;
|
|
3633
|
+
const s = spawnSession({ name: 'Daytime check', agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-check', initialInput: prompt + '\r',
|
|
3634
|
+
modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
|
|
3635
|
+
if (s.state === 'exited') return { ok: false, error: s.note || 'the check session could not start' };
|
|
3636
|
+
const c = dayRunLib.checkStarted(run, s.id, reason, now);
|
|
3637
|
+
return c.ok ? { ok: true, run: c.run, session: s.id } : c;
|
|
3638
|
+
}
|
|
3639
|
+
/** A brief for a day with a run arrived: its agent priorities become tasks and start. */
|
|
3640
|
+
function dayAfterBrief(date, merged) {
|
|
3641
|
+
const now = Date.now();
|
|
3642
|
+
let run = readDayRun(date);
|
|
3643
|
+
if (!run) return null;
|
|
3644
|
+
const brief = briefOfDay(date);
|
|
3645
|
+
if (merged && run.checks.session_id) { const c = dayRunLib.checkDone(run, merged, now); if (c.ok) run = c.run; }
|
|
3646
|
+
const a = dayRunLib.addTasks(run, brief);
|
|
3647
|
+
if (a.ok) run = a.run;
|
|
3648
|
+
run = startDayTasks(run, now);
|
|
3649
|
+
writeDayRun(run);
|
|
3650
|
+
return run;
|
|
3651
|
+
}
|
|
3652
|
+
/** The tick: mark ended sessions, start what may start, and start a check when one is due. */
|
|
3653
|
+
function dayTick() {
|
|
3654
|
+
const now = Date.now();
|
|
3655
|
+
const date = briefLib.localDate(now);
|
|
3656
|
+
let run = readDayRun(date);
|
|
3657
|
+
if (!run) return;
|
|
3658
|
+
const before = JSON.stringify(run);
|
|
3659
|
+
const ids = new Set([...run.tasks.filter((t) => (t.status === 'working' || t.status === 'sending') && t.session_id).map((t) => t.session_id),
|
|
3660
|
+
run.brief_session && !run.brief_session.ended_at ? run.brief_session.id : null, run.checks.session_id].filter(Boolean));
|
|
3661
|
+
for (const id of ids) if (!daySessionAlive(id)) run = dayRunLib.sessionEnded(run, id, now).run;
|
|
3662
|
+
const brief = briefOfDay(date);
|
|
3663
|
+
const a = dayRunLib.addTasks(run, brief);
|
|
3664
|
+
if (a.ok) run = a.run;
|
|
3665
|
+
run = startDayTasks(run, now);
|
|
3666
|
+
const due = dayRunLib.checkDue(run, { settings: proSettings(), now, events: brief ? brief.calendar || [] : [] });
|
|
3667
|
+
if (due.due) { const c = startDayCheck(run, due.reason, now); if (c.ok) run = c.run; }
|
|
3668
|
+
run.checks.next = due.next;
|
|
3669
|
+
if (JSON.stringify(run) !== before) writeDayRun(run);
|
|
3670
|
+
}
|
|
3671
|
+
// ── end of the today day run helpers ─────────────────────────────────────────────────────────────────────────────
|
|
3672
|
+
|
|
3575
3673
|
// ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
|
|
3576
3674
|
// The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
|
|
3577
3675
|
// `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
|
|
@@ -6998,13 +7096,21 @@ async function handleRequest(req, res) {
|
|
|
6998
7096
|
return json(res, { ok: true, day: r.day, goal: r.goal });
|
|
6999
7097
|
}
|
|
7000
7098
|
if (p === '/api/proactive/goals/plan' && req.method === 'POST') {
|
|
7001
|
-
// A PM press: make sure the morning brief is scheduled for this
|
|
7002
|
-
// goals with `pmos proactive goals` and submits a brief whose
|
|
7099
|
+
// A PM press ("Prepare my day"): record the day run (G13), make sure the morning brief is scheduled for this
|
|
7100
|
+
// repository, then run it now. The session reads the goals with `pmos proactive goals` and submits a brief whose
|
|
7101
|
+
// items name them. No goal is needed: the brief plans the day either way. A second press while the brief session
|
|
7102
|
+
// still runs starts nothing and answers that session.
|
|
7003
7103
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7004
7104
|
const w = getWs(body.ws);
|
|
7005
7105
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
7006
|
-
const
|
|
7007
|
-
|
|
7106
|
+
const pnow = Date.now(); const pdate = briefLib.localDate(pnow);
|
|
7107
|
+
const open = readGoalsDay(pdate).goals.filter((g) => g.status === 'open');
|
|
7108
|
+
let run = readDayRun(pdate) || dayRunLib.newRun(pdate, pnow);
|
|
7109
|
+
run.ws = w.id;
|
|
7110
|
+
if (run.brief_session && !run.brief_session.ended_at) {
|
|
7111
|
+
if (daySessionAlive(run.brief_session.id)) { writeDayRun(run); return json(res, { ok: true, running: true, session: run.brief_session.id, run, goals: open.length }); }
|
|
7112
|
+
run = dayRunLib.sessionEnded(run, run.brief_session.id, pnow).run;
|
|
7113
|
+
}
|
|
7008
7114
|
let sched = readSchedules().find((x) => x.ws === w.id && x.skill === 'morning-brief');
|
|
7009
7115
|
let seeded = false;
|
|
7010
7116
|
if (!sched) {
|
|
@@ -7016,9 +7122,69 @@ async function handleRequest(req, res) {
|
|
|
7016
7122
|
const list = readSchedules(); list.unshift(sched); writeSchedules(list); seeded = true;
|
|
7017
7123
|
}
|
|
7018
7124
|
const result = fireSchedule(sched);
|
|
7019
|
-
|
|
7125
|
+
if (result.ok && result.sessionId) run.brief_session = { id: result.sessionId, started_at: new Date(pnow).toISOString(), ended_at: null };
|
|
7126
|
+
writeDayRun(run);
|
|
7127
|
+
return json(res, { ok: !!result.ok, started: !!result.ok, session: result.sessionId || null, run, seeded, schedule: { id: sched.id, time: sched.time }, result, goals: open.length,
|
|
7020
7128
|
...(result.ok ? {} : { error: result.reason || 'the morning-brief session did not start' }) }, result.ok ? 200 : 500);
|
|
7021
7129
|
}
|
|
7130
|
+
/* ── today day run (G13, G14, G21, G22): the day view, the task sessions' reports and the PM's decisions. These
|
|
7131
|
+
* routes write only goals/<date>.run.json (writeDayRun) and start sessions only through the day-run helpers, for
|
|
7132
|
+
* the run of a day the PM pressed. Deliver, sent and fail are called by the sessions through `pmos proactive day`;
|
|
7133
|
+
* decide and check are the PM's presses. ── */
|
|
7134
|
+
if (p === '/api/proactive/day' && req.method === 'GET') {
|
|
7135
|
+
const now = Date.now();
|
|
7136
|
+
const date = briefLib.localDate(now);
|
|
7137
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
7138
|
+
const st = proSettings();
|
|
7139
|
+
const run = readDayRun(date);
|
|
7140
|
+
const stored = briefOfDay(date);
|
|
7141
|
+
const brief = stored && stored.date === date ? stored : null;
|
|
7142
|
+
let cards = []; let cardsError = null;
|
|
7143
|
+
if (w) {
|
|
7144
|
+
const memo = goalsCardsMemo.get(w.id);
|
|
7145
|
+
if (memo && now - memo.at < 30000) ({ cards, error: cardsError } = memo);
|
|
7146
|
+
else {
|
|
7147
|
+
let sess = []; try { sess = sessionList(); } catch { /* no session cards */ }
|
|
7148
|
+
const t = todayLib.todayFor(w, { stateDir: stateDirFor(w), sessions: sess, registered: (root) => { const x = workspaces.get(wsId(root)); return !!x && path.resolve(x.root) === path.resolve(root); } });
|
|
7149
|
+
if (t.ok) cards = t.cards; else cardsError = t.error;
|
|
7150
|
+
goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
|
|
7151
|
+
}
|
|
7152
|
+
}
|
|
7153
|
+
const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now });
|
|
7154
|
+
const due = dayRunLib.checkDue(run, { settings: st, now, events: brief ? brief.calendar || [] : [] });
|
|
7155
|
+
const items = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
|
|
7156
|
+
const sources = proactiveLib.BRIEF_SOURCES.map((k) => ({ k, on: st.briefSources[k] === true, state: brief ? brief.sources[k] || null : null, got: items.filter((x) => x.source === k).length }));
|
|
7157
|
+
return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, cards_error: cardsError,
|
|
7158
|
+
settings: { briefSources: st.briefSources, scopes: st.scopes, dayCheck: st.dayCheck, websites: st.websites }, sources,
|
|
7159
|
+
check: { every: st.dayCheck.every, last: run ? run.checks.last : null, next: due.next, running: !!(run && run.checks.session_id), log: run ? run.checks.log : [] } });
|
|
7160
|
+
}
|
|
7161
|
+
if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
|
|
7162
|
+
const verb = p.slice('/api/proactive/day/'.length);
|
|
7163
|
+
if (!['deliver', 'sent', 'fail', 'decide', 'check'].includes(verb)) return json(res, { ok: false, error: 'unknown day-run action' }, 404);
|
|
7164
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7165
|
+
const now = Date.now();
|
|
7166
|
+
let run = readDayRun(briefLib.localDate(now));
|
|
7167
|
+
if (!run) return json(res, { ok: false, error: 'no day run today — press Prepare my day first' }, 409);
|
|
7168
|
+
if (verb === 'check') {
|
|
7169
|
+
// "Check now": one check at once, whatever the hour. The PM's press; reason 'manual'.
|
|
7170
|
+
if (body.ws && !run.ws) run.ws = getWs(body.ws) ? getWs(body.ws).id : run.ws;
|
|
7171
|
+
const c = startDayCheck(run, 'manual', now);
|
|
7172
|
+
if (!c.ok) return json(res, { ok: false, error: c.error }, 409);
|
|
7173
|
+
writeDayRun(c.run);
|
|
7174
|
+
return json(res, { ok: true, session: c.session, run: c.run });
|
|
7175
|
+
}
|
|
7176
|
+
const id = String(body.task || '');
|
|
7177
|
+
if (!dayRunLib.findTask(run, id)) return json(res, { ok: false, error: `no task ${id || '(none given)'} in today's run` }, 404);
|
|
7178
|
+
const r = verb === 'deliver' ? dayRunLib.deliver(run, id, { summary: body.summary, body: body.body, link: body.link }, now)
|
|
7179
|
+
: verb === 'sent' ? dayRunLib.sent(run, id, { link: body.link }, now)
|
|
7180
|
+
: verb === 'fail' ? dayRunLib.fail(run, id, body.reason, now)
|
|
7181
|
+
: dayRunLib.decide(run, id, { action: body.action, note: body.note }, now);
|
|
7182
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
7183
|
+
// A delivered, sent or failed task frees a place, and a decision may make a task ready: start what may start now.
|
|
7184
|
+
run = startDayTasks(r.run, now);
|
|
7185
|
+
if (!writeDayRun(run)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
|
|
7186
|
+
return json(res, { ok: true, run, task: dayRunLib.findTask(run, id) });
|
|
7187
|
+
}
|
|
7022
7188
|
/* ── background prepare (D3): plan is read-only; queue records an approval; the runner starts it later ── */
|
|
7023
7189
|
if (p === '/api/proactive/prepare/plan' && req.method === 'POST') {
|
|
7024
7190
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -7095,9 +7261,20 @@ async function handleRequest(req, res) {
|
|
|
7095
7261
|
const bday = body && typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date) ? body.date : briefLib.localDate(Date.now());
|
|
7096
7262
|
const v = briefLib.validateBrief(body, { now: Date.now(), settings: proSettings(), goalIds: readGoalsDay(bday).goals.map((g) => g.id) });
|
|
7097
7263
|
if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
|
|
7098
|
-
|
|
7264
|
+
// `merge: true` (a daytime check, G22) merges into the stored brief of that day; with none stored it is stored as it is.
|
|
7265
|
+
let out = { ...v.brief, resolved: [] }; let merged = null;
|
|
7266
|
+
if (body.merge === true) {
|
|
7267
|
+
let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
|
|
7268
|
+
const m = briefLib.mergeBrief(stored, v.brief, { now: Date.now() });
|
|
7269
|
+
if (!m.ok) return json(res, { ok: false, error: m.error }, 400);
|
|
7270
|
+
out = m.brief; merged = { found: m.added, resolved: m.removed };
|
|
7271
|
+
}
|
|
7272
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, out.date + '.json'), JSON.stringify(out) + '\n'); }
|
|
7099
7273
|
catch (e) { warnWriteFail('briefs', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
7100
|
-
|
|
7274
|
+
// A brief for a day the PM pressed "Prepare my day": its agent priorities become tasks and start (G13), and a merge
|
|
7275
|
+
// closes the check that sent it (G22).
|
|
7276
|
+
try { dayAfterBrief(out.date, merged); } catch (e) { warnWriteFail('day run after the brief', e); }
|
|
7277
|
+
return json(res, { ok: true, brief: out, ...(merged ? { merged } : {}) });
|
|
7101
7278
|
}
|
|
7102
7279
|
if (req.method === 'GET') {
|
|
7103
7280
|
const now = Date.now(); const st = proSettings();
|
|
@@ -7113,7 +7290,11 @@ async function handleRequest(req, res) {
|
|
|
7113
7290
|
if (req.method === 'POST') {
|
|
7114
7291
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7115
7292
|
const cur = proSettings();
|
|
7116
|
-
|
|
7293
|
+
// A patch: triggers, sources, the check and each service's levels merge over what is stored (a level of null clears it).
|
|
7294
|
+
const bs = body.scopes && typeof body.scopes === 'object' && !Array.isArray(body.scopes) ? body.scopes : null;
|
|
7295
|
+
const scopes = bs ? { ...cur.scopes, ...Object.fromEntries(Object.entries(bs).map(([k, v]) => [k, v && typeof v === 'object' && !Array.isArray(v) ? { ...(cur.scopes[k] || {}), ...v } : v])) } : cur.scopes;
|
|
7296
|
+
const dayCheck = body.dayCheck && typeof body.dayCheck === 'object' && !Array.isArray(body.dayCheck) ? { ...cur.dayCheck, ...body.dayCheck } : (body.dayCheck === undefined ? cur.dayCheck : body.dayCheck);
|
|
7297
|
+
const n = proactiveLib.normalizeSettings({ ...cur, ...body, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) }, scopes, dayCheck });
|
|
7117
7298
|
if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
|
|
7118
7299
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PRO_SETTINGS(), JSON.stringify(n.settings) + '\n'); }
|
|
7119
7300
|
catch (e) { warnWriteFail('proactive-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|