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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-one-home-test.js +4 -2
- package/workspace/c3-work-test.js +200 -0
- package/workspace/fixtures/one-home-baseline.json +0 -91
- package/workspace/guide/console-calendar.md +65 -0
- package/workspace/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/my-work.md +76 -1
- package/workspace/lib/calendar-cli.js +84 -0
- package/workspace/lib/calendar-layers.js +372 -0
- package/workspace/lib/facts-async.js +144 -35
- package/workspace/lib/freshness.js +167 -0
- package/workspace/lib/offloop.js +94 -0
- package/workspace/lib/pm-items.js +71 -6
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-calendar-views.js +112 -3
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-pm-items.js +123 -13
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/lib/work-focus.js +106 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +392 -0
- package/workspace/public/pm/c3-demo-cal4.js +114 -0
- package/workspace/public/pm/c3-demo-data.js +79 -77
- package/workspace/public/pm/c3-demo-misc2.js +4 -16
- package/workspace/public/pm/c3-demo-work-ix.js +19 -67
- package/workspace/public/pm/c3-demo.js +10 -3
- package/workspace/public/pm/c3-shell.js +5 -2
- package/workspace/public/pm/calendar-views.js +10 -3
- package/workspace/public/pm/components.css +34 -0
- package/workspace/public/pm/console3.css +155 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/public/pm/pm-work-view.js +364 -172
- package/workspace/public/pm/work-place.js +58 -0
- package/workspace/server.js +39 -2
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/calendar-layers.json +5 -0
- package/workspace/test-lists/calendar-time-page.json +5 -0
- package/workspace/test-lists/console3-work-page.json +9 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -0
- 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 }
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.132.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED