create-pmos 0.130.0 → 0.132.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 (51) 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-one-home-test.js +4 -2
  7. package/workspace/c3-work-test.js +200 -0
  8. package/workspace/fixtures/one-home-baseline.json +0 -91
  9. package/workspace/guide/console-calendar.md +65 -0
  10. package/workspace/guide/data-freshness.md +40 -0
  11. package/workspace/guide/index.md +2 -0
  12. package/workspace/guide/my-work.md +76 -1
  13. package/workspace/lib/calendar-cli.js +84 -0
  14. package/workspace/lib/calendar-layers.js +372 -0
  15. package/workspace/lib/facts-async.js +144 -35
  16. package/workspace/lib/freshness.js +167 -0
  17. package/workspace/lib/offloop.js +94 -0
  18. package/workspace/lib/pm-items.js +71 -6
  19. package/workspace/lib/pmos-verbs.js +3 -0
  20. package/workspace/lib/report-needs.js +1 -1
  21. package/workspace/lib/route-modules.js +1 -0
  22. package/workspace/lib/routes-board.js +3 -1
  23. package/workspace/lib/routes-calendar-views.js +112 -3
  24. package/workspace/lib/routes-my-work.js +27 -9
  25. package/workspace/lib/routes-pm-items.js +123 -13
  26. package/workspace/lib/routes-sources.js +64 -0
  27. package/workspace/lib/work-focus.js +106 -0
  28. package/workspace/package-lock.json +2 -2
  29. package/workspace/package.json +1 -1
  30. package/workspace/public/pm/c3-calendar.js +392 -0
  31. package/workspace/public/pm/c3-demo-cal4.js +114 -0
  32. package/workspace/public/pm/c3-demo-data.js +79 -77
  33. package/workspace/public/pm/c3-demo-misc2.js +4 -16
  34. package/workspace/public/pm/c3-demo-work-ix.js +19 -67
  35. package/workspace/public/pm/c3-demo.js +10 -3
  36. package/workspace/public/pm/c3-shell.js +5 -2
  37. package/workspace/public/pm/calendar-views.js +10 -3
  38. package/workspace/public/pm/components.css +34 -0
  39. package/workspace/public/pm/console3.css +155 -0
  40. package/workspace/public/pm/dest-settings.js +10 -1
  41. package/workspace/public/pm/freshness-line.js +123 -0
  42. package/workspace/public/pm/pm-work-view.js +364 -172
  43. package/workspace/public/pm/work-place.js +58 -0
  44. package/workspace/server.js +39 -2
  45. package/workspace/skills/calendar-sync.skill +30 -0
  46. package/workspace/test-files.json +2 -0
  47. package/workspace/test-lists/calendar-layers.json +5 -0
  48. package/workspace/test-lists/calendar-time-page.json +5 -0
  49. package/workspace/test-lists/console3-work-page.json +9 -0
  50. package/workspace/test-lists/workspace-data-freshness.json +6 -0
  51. package/workspace/public/pm/c3-demo-work2.js +0 -13
@@ -1,13 +1,29 @@
1
1
  'use strict';
2
- /* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice).
2
+ /* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice; the
3
+ * console3 Work page: lib/pm-items.js workPlacement, lib/work-focus.js).
3
4
  *
4
- * GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items }
5
+ * GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items,
6
+ * work: { inbox, today, upcoming, waiting, someday } open items, placed once here
7
+ * review, review_days: 7 open items untouched 7 days, most stale first
8
+ * focus: { suggestions, accepted } at most 3 suggestions: { key, title, why, item_ids, job }
9
+ * counts: { inbox, today, upcoming, waiting, someday, review } }
5
10
  * POST /api/pm-items/add { line } → { ok, item } one line: the parser (lib/pm-items.js) reads the parts
6
- * POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job
11
+ * POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job,
12
+ * defer, flagged, resolves, kind, person, evidence, outcome
7
13
  * POST /api/pm-items/remove { id } → { ok, undo_ms } deleted (created here) or dropped (imported)
8
14
  * POST /api/pm-items/undo { id } → { ok, item } | 409 restores the last removal inside the 8 s window
9
- * POST /api/pm-items/done { id } → { ok } folds to item.done + done_at; /reopen undoes it
15
+ * POST /api/pm-items/done { id, outcome? } → { ok } folds to item.done + done_at (+ outcome, one line); /reopen undoes it
10
16
  * POST /api/pm-items/park { id } → { ok } parked: true, placed in Later; /unpark clears it
17
+ * POST /api/pm-items/flag { id, flagged } → { ok } flagged: boolean; a flagged item is in Today
18
+ * POST /api/pm-items/defer { id, until|null } → { ok } defer: not before that date (null clears); clears parked
19
+ * POST /api/pm-items/someday { id } → { ok } parked: true, placed in Someday
20
+ * POST /api/pm-items/waiting { id, on|null } → { ok } waiting_on and waiting_since (null clears); clears parked
21
+ * POST /api/pm-items/notyet { id, when } → { ok } when: later-today | tomorrow | next-week; adds 1 to not_yet_count;
22
+ * tomorrow and next-week set defer (next Monday), later-today sets nudge_at (now + 3 h)
23
+ * POST /api/pm-items/keep { id } → { ok } the Review pass kept it: event 'kept', touched_at moves
24
+ * POST /api/pm-items/focus/accept { key, title, why, item_ids, job? } → { ok } today's Focus (focus.jsonl)
25
+ * POST /api/pm-items/focus/dismiss { key } → { ok } not suggested again today
26
+ * POST /api/pm-items/focus/clear { key } → { ok } forget the accept or the dismissal
11
27
  * POST /api/pm-items/move { id, list } → { ok } list_override, one of the five lists
12
28
  * POST /api/pm-items/steps/add { id, text, who? } → { ok, step } outward steps are forced to agent-drafts
13
29
  * POST /api/pm-items/steps/who { step_id, who } → { ok, who }
@@ -16,7 +32,7 @@
16
32
  * POST /api/pm-jobs/edit { id, fields }→ { ok, job } fields: when, want, so
17
33
  * POST /api/pm-jobs/delete { id, reassign_to? } → { ok, reassigned } items move to reassign_to, or to none
18
34
  *
19
- * STATE. Two append-only files under <runtime>/pm-items/: items.jsonl and jobs.jsonl. Every write is one appended line
35
+ * STATE. Append-only files under <runtime>/pm-items/: items.jsonl, jobs.jsonl, steps.jsonl and focus.jsonl. Every write is one appended line
20
36
  * through pm-items.appendEvent, the single writer, and the GET folds the whole file again. Nothing is read from Jira.
21
37
  *
22
38
  * WHO WRITES. Every POST passes pm-guard requirePagePress: the page only, with the Origin and nonce checks. An agent
@@ -28,9 +44,12 @@ const crypto = require('crypto');
28
44
  const guard = require('./pm-guard.js');
29
45
  const pmItems = require('./pm-items.js');
30
46
  const jobs = require('./jobs-to-be-done.js');
47
+ const workFocus = require('./work-focus.js');
31
48
  const steps = require('./item-steps.js');
32
49
 
33
- const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job'];
50
+ const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job', 'defer', 'flagged', 'resolves', 'kind', 'person', 'evidence', 'outcome'];
51
+ const STRING_OR_NULL = ['resolves', 'person', 'evidence', 'outcome'];
52
+ const NOT_YET = ['later-today', 'tomorrow', 'next-week'];
34
53
  const JOB_FIELDS = ['when', 'want', 'so'];
35
54
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
36
55
 
@@ -38,17 +57,40 @@ const dirOf = (ctx) => path.join(ctx.runtimeDir, 'pm-items');
38
57
  const itemsFile = (ctx) => path.join(dirOf(ctx), 'items.jsonl');
39
58
  const stepsFile = (ctx) => path.join(dirOf(ctx), 'steps.jsonl');
40
59
  const jobsFile = (ctx) => path.join(dirOf(ctx), 'jobs.jsonl');
60
+ const focusFile = (ctx) => path.join(dirOf(ctx), 'focus.jsonl');
41
61
  const newId = (prefix) => `${prefix}-${crypto.randomBytes(6).toString('hex')}`;
42
62
 
43
63
  // The current view, folded from the files. `now` decides placement, so tests can pin the day.
44
- function view(itemLines, jobLines, now, stepLines = []) {
64
+ // The titles of the team tasks (plan items) that own items advance, so the page can say "Advances: <title>". Best effort: no workspace, no map.
65
+ function resolvesTitles(items, ctx, url) {
66
+ const ids = [...new Set((items || []).map((i) => i.resolves).filter(Boolean))];
67
+ if (!ids.length || typeof ctx.getWs !== 'function') return {};
68
+ try {
69
+ const w = ctx.getWs(String(url.searchParams.get('ws') || ''));
70
+ if (!w || !w.root) return {};
71
+ const fold = require('./plan-items.js').readFold(w.root);
72
+ const out = {};
73
+ for (const id of ids) if (fold.items[id] && fold.items[id].title) out[id] = fold.items[id].title;
74
+ return out;
75
+ } catch { return {}; }
76
+ }
77
+
78
+ function view(itemLines, jobLines, now, stepLines = [], focusLines = []) {
45
79
  const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
46
80
  const byItem = steps.foldSteps(stepLines).byItem;
47
81
  const items = pmItems.fold(itemLines).map((i) => ({ ...i, steps: byItem[i.id] || [] }));
48
82
  const currentJobs = jobs.foldJobs(jobLines).map((j) => ({ ...j, ...jobs.progress(j.id, items) }));
49
83
  const lists = Object.fromEntries(pmItems.LISTS.map((l) => [l, []]));
50
84
  for (const it of items) lists[pmItems.placement(it, today)].push(it);
51
- return { ok: true, today: today.toISOString().slice(0, 10), lists, jobs: currentJobs, items };
85
+ const todayIso = today.toISOString().slice(0, 10);
86
+ const work = Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, []]));
87
+ for (const it of items) if (!it.done) work[pmItems.workPlacement(it, today)].push(it);
88
+ for (const l of pmItems.WORK_LISTS) work[l] = pmItems.sortWorkList(l, work[l], today);
89
+ const review = pmItems.reviewQueue(items, now);
90
+ const state = workFocus.foldFocus(focusLines, todayIso);
91
+ const focus = { suggestions: workFocus.focusSuggestions(work.today, currentJobs, state, todayIso), accepted: state.accepted };
92
+ const counts = { ...Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, work[l].length])), review: review.length };
93
+ return { ok: true, today: todayIso, lists, jobs: currentJobs, items, work, review, review_days: pmItems.REVIEW_DAYS, focus, counts };
52
94
  }
53
95
 
54
96
  // Each operation returns { status, body, events } and writes nothing itself; the caller appends `events`.
@@ -59,6 +101,12 @@ function checkFields(fields, allowed) {
59
101
  }
60
102
  if ('due' in fields && fields.due !== null && !DATE_RE.test(String(fields.due))) return 'due must be YYYY-MM-DD or null';
61
103
  if ('priority' in fields && fields.priority !== null && !/^P[1-3]$/.test(String(fields.priority))) return 'priority must be P1, P2, P3 or null';
104
+ if ('defer' in fields && fields.defer !== null && !DATE_RE.test(String(fields.defer))) return 'defer must be YYYY-MM-DD or null';
105
+ if ('flagged' in fields && typeof fields.flagged !== 'boolean') return 'flagged must be true or false';
106
+ if ('kind' in fields && fields.kind !== null && fields.kind !== 'offline') return 'kind must be offline or null';
107
+ for (const k of STRING_OR_NULL) {
108
+ if (k in fields && fields[k] !== null && typeof fields[k] !== 'string') return `${k} must be a string or null`;
109
+ }
62
110
  return null;
63
111
  }
64
112
 
@@ -98,7 +146,11 @@ function flagItem(body, itemLines, events) {
98
146
  if (!item) return noItem;
99
147
  return { status: 200, body: { ok: true }, events: typeof events === 'function' ? events(item) : events };
100
148
  }
101
- const doneItem = (b, l) => flagItem(b, l, [{ event: 'done', id: b.id }]);
149
+ function doneItem(b, l) {
150
+ if (b.outcome !== undefined && b.outcome !== null && typeof b.outcome !== 'string') return { status: 400, body: { ok: false, error: 'outcome must be a string' } };
151
+ const outcome = typeof b.outcome === 'string' && b.outcome.trim() ? { outcome: b.outcome.trim() } : {};
152
+ return flagItem(b, l, [{ event: 'done', id: b.id, ...outcome }]);
153
+ }
102
154
  const reopenItem = (b, l) => flagItem(b, l, [{ event: 'reopened', id: b.id }]);
103
155
  const parkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
104
156
  const unparkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: false } }]);
@@ -107,6 +159,53 @@ function moveItem(b, l) {
107
159
  return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { list_override: b.list, parked: false } }]);
108
160
  }
109
161
 
162
+ // Work page routes. A legacy list_override would beat the new placement, so the routes that place an item clear it.
163
+ const bad = (error) => ({ status: 400, body: { ok: false, error } });
164
+ function flagRoute(b, l) {
165
+ if (typeof b.flagged !== 'boolean') return bad('flagged must be true or false');
166
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { flagged: b.flagged } }]);
167
+ }
168
+ function deferItem(b, l) {
169
+ if (b.until !== null && !DATE_RE.test(String(b.until))) return bad('until must be YYYY-MM-DD or null');
170
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { defer: b.until, parked: false, list_override: null } }]);
171
+ }
172
+ const somedayItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
173
+ function waitingItem(b, l, now) {
174
+ const on = typeof b.on === 'string' ? b.on.trim() : b.on;
175
+ if (on !== null && (typeof on !== 'string' || !on)) return bad('on must be a name or null');
176
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { waiting_on: on, waiting_since: on ? now.toISOString() : null, parked: false, list_override: null } }]);
177
+ }
178
+ // "Not yet": tomorrow and next Monday are dates (defer); later today only counts and nudges 3 hours on.
179
+ // Dates follow the server's UTC day, as the placement does.
180
+ function notYetItem(b, l, now) {
181
+ if (!NOT_YET.includes(b.when)) return bad('when must be one of ' + NOT_YET.join(', '));
182
+ return flagItem(b, l, (item) => {
183
+ const fields = { not_yet_count: (item.not_yet_count || 0) + 1 };
184
+ const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
185
+ const plus = (n) => new Date(day.getTime() + n * 86400000).toISOString().slice(0, 10);
186
+ if (b.when === 'later-today') fields.nudge_at = new Date(now.getTime() + 3 * 3600000).toISOString();
187
+ else {
188
+ fields.defer = b.when === 'tomorrow' ? plus(1) : plus(((1 - day.getUTCDay() + 7) % 7) || 7);
189
+ fields.nudge_at = null;
190
+ fields.list_override = null;
191
+ }
192
+ return [{ event: 'edited', id: b.id, fields }];
193
+ });
194
+ }
195
+ const keepItem = (b, l) => flagItem(b, l, [{ event: 'kept', id: b.id }]);
196
+
197
+ // Focus events go to focus.jsonl. The day comes from the server date, never from the page.
198
+ function focusEvent(kind, b, now) {
199
+ if (typeof b.key !== 'string' || !b.key) return bad('key is required');
200
+ const day = now.toISOString().slice(0, 10);
201
+ if (kind !== 'accepted') return { status: 200, body: { ok: true }, events: [{ event: kind, key: b.key, day }] };
202
+ if (typeof b.title !== 'string' || !b.title.trim()) return bad('title is required');
203
+ if (typeof b.why !== 'string') return bad('why must be a string');
204
+ if (!Array.isArray(b.item_ids) || b.item_ids.some((x) => typeof x !== 'string')) return bad('item_ids must be a list of item ids');
205
+ if (b.job !== undefined && b.job !== null && typeof b.job !== 'string') return bad('job must be a string or null');
206
+ return { status: 200, body: { ok: true }, events: [{ event: 'accepted', key: b.key, title: b.title.trim(), why: b.why, item_ids: b.item_ids, job: b.job || null, day }] };
207
+ }
208
+
110
209
  function addStepRoute(b, itemLines) {
111
210
  if (!findItem(itemLines, b.id)) return noItem;
112
211
  const id = newId('step');
@@ -172,6 +271,15 @@ const ROUTES = {
172
271
  'POST /api/pm-items/park': (b, ctx) => ({ ...parkItem(b, ctx.itemLines), file: 'items' }),
173
272
  'POST /api/pm-items/unpark': (b, ctx) => ({ ...unparkItem(b, ctx.itemLines), file: 'items' }),
174
273
  'POST /api/pm-items/move': (b, ctx) => ({ ...moveItem(b, ctx.itemLines), file: 'items' }),
274
+ 'POST /api/pm-items/flag': (b, ctx) => ({ ...flagRoute(b, ctx.itemLines), file: 'items' }),
275
+ 'POST /api/pm-items/defer': (b, ctx) => ({ ...deferItem(b, ctx.itemLines), file: 'items' }),
276
+ 'POST /api/pm-items/someday': (b, ctx) => ({ ...somedayItem(b, ctx.itemLines), file: 'items' }),
277
+ 'POST /api/pm-items/waiting': (b, ctx) => ({ ...waitingItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
278
+ 'POST /api/pm-items/notyet': (b, ctx) => ({ ...notYetItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
279
+ 'POST /api/pm-items/keep': (b, ctx) => ({ ...keepItem(b, ctx.itemLines), file: 'items' }),
280
+ 'POST /api/pm-items/focus/accept': (b, ctx) => ({ ...focusEvent('accepted', b, ctx.now || new Date()), file: 'focus' }),
281
+ 'POST /api/pm-items/focus/dismiss': (b, ctx) => ({ ...focusEvent('dismissed', b, ctx.now || new Date()), file: 'focus' }),
282
+ 'POST /api/pm-items/focus/clear': (b, ctx) => ({ ...focusEvent('cleared', b, ctx.now || new Date()), file: 'focus' }),
175
283
  'POST /api/pm-items/steps/add': (b, ctx) => ({ ...addStepRoute(b, ctx.itemLines), file: 'steps' }),
176
284
  'POST /api/pm-items/steps/who': (b, ctx) => ({ ...stepWho(b, ctx.stepLines), file: 'steps' }),
177
285
  'POST /api/pm-items/steps/plan': (b, ctx) => ({ ...planSteps(b, ctx.itemLines), file: 'steps' }),
@@ -181,17 +289,19 @@ const ROUTES = {
181
289
  };
182
290
 
183
291
  function prepare(ctx, now = new Date()) {
184
- return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), now };
292
+ return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), focusLines: pmItems.readLines(focusFile(ctx)), now };
185
293
  }
186
294
 
187
295
  module.exports = {
188
296
  prefixes: ['/api/pm-items', '/api/pm-jobs'],
189
- view, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
297
+ view, resolvesTitles, flagRoute, deferItem, somedayItem, waitingItem, notYetItem, keepItem, focusEvent, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
190
298
  handle: async (req, res, url, ctx) => {
191
299
  const p = url.pathname;
192
300
  if (p === '/api/pm-items' && req.method === 'GET') {
193
301
  const c = prepare(ctx);
194
- ctx.json(res, view(c.itemLines, c.jobLines, c.now, c.stepLines));
302
+ const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines);
303
+ v.resolves_titles = resolvesTitles(v.items, ctx, url);
304
+ ctx.json(res, v);
195
305
  return true;
196
306
  }
197
307
  const key = `${req.method} ${p}`;
@@ -207,7 +317,7 @@ module.exports = {
207
317
  for (const ev of r.events) {
208
318
  const which = ev._file || r.file;
209
319
  const { _file, ...line } = ev;
210
- pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : itemsFile(ctx), line, c.now);
320
+ pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : which === 'focus' ? focusFile(ctx) : itemsFile(ctx), line, c.now);
211
321
  }
212
322
  }
213
323
  ctx.json(res, r.body, r.status);
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* routes-sources.js — how old each source of a workspace is, and Refresh now (workspace-data-freshness R6, R7).
3
+ *
4
+ * GET /api/sources?ws=<id> → { ok, ws, version, sources: [row] } lib/freshness.js sources()
5
+ * POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
6
+ * POST /api/sources/watch { ws, on } → { ok, watch } the change detector on or off for this repository
7
+ *
8
+ * Both POSTs pass pm-guard requirePagePress: the PM's press from the page. An agent session cannot start a refresh or
9
+ * switch the detector off. A refresh of `repository` or `initiatives` rebuilds the facts in a child process; a refresh of
10
+ * an external source starts that source's skill as a session in the workspace, with the PM's own connections. The server
11
+ * itself calls nothing outside this machine.
12
+ */
13
+ const guard = require('./pm-guard.js');
14
+ const freshness = require('./freshness.js');
15
+
16
+ function wsOf(ctx, id) {
17
+ const w = ctx.getWs(id || '');
18
+ return w && (!id || w.id === id) ? w : null;
19
+ }
20
+ const repoStateDir = (ctx, w) => { try { return ctx.stateDirFor ? ctx.stateDirFor(w) : null; } catch { return null; } };
21
+
22
+ async function handle(req, res, url, ctx) {
23
+ const p = url.pathname;
24
+ const F = ctx.freshness;
25
+ if (!F) return false;
26
+ if (p === '/api/sources') {
27
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
28
+ const w = wsOf(ctx, url.searchParams.get('ws'));
29
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
30
+ const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) });
31
+ ctx.json(res, { ok: true, ws: w.id, version: F.versions()[w.id] || 0, sources });
32
+ return true;
33
+ }
34
+ if (p !== '/api/sources/refresh' && p !== '/api/sources/watch') return false;
35
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
36
+ const got = await ctx.readJson(req);
37
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'the body is not JSON' }, 400); return true; }
38
+ const body = got.body || {};
39
+ const g = guard.requirePagePress(req, body);
40
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
41
+ const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : '');
42
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
43
+
44
+ if (p === '/api/sources/watch') {
45
+ if (typeof body.on !== 'boolean') { ctx.json(res, { ok: false, error: 'on must be true or false' }, 400); return true; }
46
+ if (!F.setEnabled(w.root, body.on)) { ctx.json(res, { ok: false, error: 'the setting could not be saved' }, 500); return true; }
47
+ ctx.json(res, { ok: true, watch: F.enabled(w.root) });
48
+ return true;
49
+ }
50
+
51
+ const src = freshness.SOURCES.find((s) => s.id === body.source);
52
+ if (!src) { ctx.json(res, { ok: false, error: 'source must be one of ' + freshness.SOURCES.map((s) => s.id).join(', ') }, 400); return true; }
53
+ if (!src.skill) {
54
+ ctx.factsRefresh(w.root);
55
+ ctx.json(res, { ok: true, source: src.id, started: 'rebuild' });
56
+ return true;
57
+ }
58
+ const r = ctx.startSkill(w.id, src.skill, `${src.label} · refresh`);
59
+ if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 500); return true; }
60
+ ctx.json(res, { ok: true, source: src.id, started: 'session', session: r.session });
61
+ return true;
62
+ }
63
+
64
+ module.exports = { prefixes: ['/api/sources'], handle };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ /* work-focus.js — Focus on the Work page: up to three jobs PMOS suggests for today (console3 PRD R4).
3
+ *
4
+ * Rules first, no model call. Pure functions; the route layer reads and writes focus.jsonl.
5
+ *
6
+ * focusSuggestions(workToday, jobs, state, todayIso)
7
+ * Groups the Today items by their job (an item with no job is its own group, key 'item:<id>'; a job group's key
8
+ * is 'job:<jobId>'), scores each group, and returns the best three as { key, title, why, item_ids, job }.
9
+ * Score per item: overdue 3 per day (at most 9), due today 2, flagged 2, P1 2, offline 1. A group scores the
10
+ * sum of its items. A key the PM accepted or dismissed today is not suggested again.
11
+ *
12
+ * foldFocus(lines, todayIso)
13
+ * The focus.jsonl events 'accepted', 'dismissed' and 'cleared' carry `day` (YYYY-MM-DD, set by the route from the
14
+ * server date). Only today's events count, so Focus starts empty each day. Returns { accepted, dismissed }.
15
+ */
16
+ const MAX_SUGGESTIONS = 3;
17
+ const DAY_MS = 86400000;
18
+
19
+ const dayNumber = (isoDay) => Math.floor(Date.parse(`${isoDay}T00:00:00Z`) / DAY_MS);
20
+
21
+ function parts(item, todayIso) {
22
+ const overdueDays = item.due && item.due < todayIso ? dayNumber(todayIso) - dayNumber(item.due) : 0;
23
+ return {
24
+ overdueDays,
25
+ dueToday: item.due === todayIso,
26
+ flagged: item.flagged === true,
27
+ p1: item.priority === 'P1',
28
+ offline: item.kind === 'offline',
29
+ };
30
+ }
31
+
32
+ function itemScore(p) {
33
+ return Math.min(p.overdueDays * 3, 9) + (p.dueToday ? 2 : 0) + (p.flagged ? 2 : 0) + (p.p1 ? 2 : 0) + (p.offline ? 1 : 0);
34
+ }
35
+
36
+ // Plain words, no dashes: "2 items overdue, 1 flagged".
37
+ function whyText(list) {
38
+ const n = (f) => list.filter(f).length;
39
+ const overdue = n((p) => p.overdueDays > 0);
40
+ const bits = [];
41
+ if (overdue) bits.push(`${overdue} ${overdue === 1 ? 'item' : 'items'} overdue`);
42
+ if (n((p) => p.dueToday)) bits.push(`${n((p) => p.dueToday)} due today`);
43
+ if (n((p) => p.flagged)) bits.push(`${n((p) => p.flagged)} flagged`);
44
+ if (n((p) => p.p1)) bits.push(`${n((p) => p.p1)} at P1`);
45
+ if (n((p) => p.offline)) bits.push(`${n((p) => p.offline)} outside PMOS`);
46
+ return bits.length ? bits.join(', ') : 'Planned for today';
47
+ }
48
+
49
+ function jobTitle(job) {
50
+ const t = job && (job.want || job.when || job.so);
51
+ return t ? String(t).trim() : 'A standing job';
52
+ }
53
+
54
+ function focusSuggestions(workToday, jobs, state, todayIso) {
55
+ const blocked = new Set([...(state && state.accepted ? state.accepted.map((a) => a.key) : []), ...(state && state.dismissed ? state.dismissed : [])]);
56
+ const jobById = new Map((jobs || []).map((j) => [j.id, j]));
57
+ const groups = new Map();
58
+ for (const item of workToday || []) {
59
+ const job = item.job && jobById.has(item.job) ? item.job : null;
60
+ const key = job ? `job:${job}` : `item:${item.id}`;
61
+ if (!groups.has(key)) groups.set(key, { key, job, members: [] });
62
+ const p = parts(item, todayIso);
63
+ groups.get(key).members.push({ item, p, score: itemScore(p) });
64
+ }
65
+ const out = [];
66
+ for (const g of groups.values()) {
67
+ if (blocked.has(g.key)) continue;
68
+ g.members.sort((a, b) => b.score - a.score);
69
+ out.push({
70
+ key: g.key,
71
+ score: g.members.reduce((sum, m) => sum + m.score, 0),
72
+ earliest: g.members.map((m) => m.item.due || '9999').sort()[0],
73
+ suggestion: {
74
+ key: g.key,
75
+ title: g.job ? jobTitle(jobById.get(g.job)) : g.members[0].item.title,
76
+ why: whyText(g.members.map((m) => m.p)),
77
+ item_ids: g.members.map((m) => m.item.id),
78
+ job: g.job,
79
+ },
80
+ });
81
+ }
82
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
83
+ out.sort((a, b) => b.score - a.score || cmp(a.earliest, b.earliest) || cmp(a.key, b.key));
84
+ return out.slice(0, MAX_SUGGESTIONS).map((o) => o.suggestion);
85
+ }
86
+
87
+ function foldFocus(lines, todayIso) {
88
+ const accepted = new Map();
89
+ const dismissed = new Set();
90
+ for (const ev of lines || []) {
91
+ if (!ev || !ev.key || ev.day !== todayIso) continue;
92
+ if (ev.event === 'accepted') {
93
+ dismissed.delete(ev.key);
94
+ accepted.set(ev.key, { key: ev.key, title: ev.title, why: ev.why, item_ids: ev.item_ids || [], job: ev.job || null });
95
+ } else if (ev.event === 'dismissed') {
96
+ accepted.delete(ev.key);
97
+ dismissed.add(ev.key);
98
+ } else if (ev.event === 'cleared') {
99
+ accepted.delete(ev.key);
100
+ dismissed.delete(ev.key);
101
+ }
102
+ }
103
+ return { accepted: [...accepted.values()], dismissed };
104
+ }
105
+
106
+ module.exports = { MAX_SUGGESTIONS, focusSuggestions, foldFocus };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.130.0",
3
+ "version": "0.132.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.130.0",
9
+ "version": "0.132.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.130.0",
3
+ "version": "0.132.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",