create-pmos 0.167.0 → 0.168.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/extension/README.md +33 -0
- package/workspace/extension/address.js +20 -0
- package/workspace/extension/content.js +76 -0
- package/workspace/extension/icons/icon-128.png +0 -0
- package/workspace/extension/icons/icon-16.png +0 -0
- package/workspace/extension/icons/icon-32.png +0 -0
- package/workspace/extension/icons/icon-48.png +0 -0
- package/workspace/extension/manifest.json +15 -0
- package/workspace/extension/options.css +33 -0
- package/workspace/extension/options.html +26 -0
- package/workspace/extension/options.js +24 -0
- package/workspace/extension/panel.css +21 -0
- package/workspace/extension/panel.html +14 -0
- package/workspace/extension/panel.js +67 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +79 -0
- package/workspace/lib/driver-cli.js +207 -0
- package/workspace/lib/driver-decide.js +118 -0
- package/workspace/lib/driver-facts.js +113 -0
- package/workspace/lib/driver-needs.js +107 -0
- package/workspace/lib/driver-run.js +187 -0
- package/workspace/lib/driver.js +408 -0
- package/workspace/lib/extension-cli.js +39 -0
- package/workspace/lib/headless.js +13 -2
- package/workspace/lib/meeting-focus.js +48 -1
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/pmos-verbs.js +6 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +11 -1
- package/workspace/lib/routes-driver.js +135 -0
- package/workspace/lib/routes-meeting-focus.js +17 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-focus.js +12 -2
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-focus.css +31 -0
- package/workspace/public/pm/c3-focus.js +122 -21
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/server.js +21 -1
- package/workspace/skills/initiative-drive.skill +50 -0
- package/workspace/skills/work-unblock.skill +42 -0
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/c3-meeting-focus.json +13 -4
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
|
|
4
4
|
* jobs that need no human run headless).
|
|
5
5
|
*
|
|
6
|
-
* kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | null (null: open a pane, as before)
|
|
6
|
+
* kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | null (null: open a pane, as before)
|
|
7
7
|
* argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
|
|
8
8
|
* parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
|
|
9
9
|
* logLines(result, exitCode) → the lines the read-only log pane shows at the end
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* It cannot ask for permission, so each kind gets a FIXED tool list here; a connector tool runs only where the
|
|
15
15
|
* PM's own Claude Code settings already allow it, and anything else is denied and reported. No bypass flag is
|
|
16
16
|
* ever passed. `PMOS_HEADLESS=off` sends every job back to a pane.
|
|
17
|
+
*
|
|
18
|
+
* The `drive` kind is the Initiative Driver (initiative initiative-driver): origin `drive:<kind>:<id>`. It reads, and it files
|
|
19
|
+
* proposals with `pmos driver propose`. It has no Write, no Edit, no `git push`, no web fetch and no connector write tool: no
|
|
20
|
+
* connector tool is named in its list, so a connector read runs only where the PM's own Claude Code settings already allow it,
|
|
21
|
+
* and a connector write is denied and reported like any other tool outside the list. The PM-only `pmos driver` verbs refuse an
|
|
22
|
+
* agent session by themselves. The driver never runs in a pane (server.js refuses to start it when PMOS_HEADLESS=off).
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
// The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
|
|
@@ -25,12 +31,16 @@ const HEADLESS_SKILLS = Object.freeze([
|
|
|
25
31
|
const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
|
|
26
32
|
// page-starts-agent: Ask the agent on a document. The run reads and answers with the revised text; it writes nothing.
|
|
27
33
|
const ASK_ORIGINS = Object.freeze(['doc-ask:', 'focus-companion:']); // focus-companion: the Meeting focus page's quiet companion and its private questions (read-only, nothing sent)
|
|
34
|
+
// initiative-driver: one short run for one initiative or one Work task. Its origin is `drive:<kind>:<id>`.
|
|
35
|
+
const DRIVE_ORIGINS = Object.freeze(['drive:']);
|
|
28
36
|
|
|
29
37
|
// What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
|
|
30
38
|
// file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
|
|
31
39
|
const TOOLS = Object.freeze({
|
|
32
40
|
notes: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(mv *)']),
|
|
33
41
|
ask: Object.freeze(['Read', 'Glob', 'Grep']),
|
|
42
|
+
drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
|
|
43
|
+
'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
|
|
34
44
|
skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
|
|
35
45
|
'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
|
|
36
46
|
});
|
|
@@ -46,6 +56,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
|
|
|
46
56
|
const o = String(origin || '');
|
|
47
57
|
if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
|
|
48
58
|
if (ASK_ORIGINS.some((p) => o.startsWith(p))) return 'ask';
|
|
59
|
+
if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
|
|
49
60
|
if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
|
|
50
61
|
return null;
|
|
51
62
|
}
|
|
@@ -96,4 +107,4 @@ function logLines(r, exitCode) {
|
|
|
96
107
|
return out;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
|
|
110
|
+
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* foldState(lines, { topics, startMs, nowMs }) → the page's state the append-only focus/<id>.jsonl folded
|
|
10
10
|
* outLine(parsed, at) · moveLine(...) → the lines the state file takes
|
|
11
11
|
* shouldRun(now, state, note, opts) → { run, reason } the rules of when the companion may run
|
|
12
|
+
* pickNow(events, { now, meet, title }) → { ok, event, date, by } | { ok:false, status:404, error, choices } which meeting a Google Meet call is (the Chrome extension, c3-meet-extension)
|
|
12
13
|
*
|
|
13
14
|
* Nothing here reads a file or starts a process; lib/routes-meeting-focus.js does both.
|
|
14
15
|
*
|
|
@@ -289,4 +290,50 @@ function shouldRun(now, state, note, opts = {}) {
|
|
|
289
290
|
return { run: true, reason: 'new lines' };
|
|
290
291
|
}
|
|
291
292
|
|
|
292
|
-
|
|
293
|
+
/* ── which meeting is this Google Meet call (c3-meet-extension) ───────────────────────────────────────────────────── */
|
|
294
|
+
|
|
295
|
+
const MEET_CODE = /\b[a-z]{3}-[a-z]{4}-[a-z]{3}\b/i;
|
|
296
|
+
const NOW_SLACK_MS = 10 * 60000; // a call may start 10 minutes early and run 10 minutes over
|
|
297
|
+
const DEFAULT_LEN_MS = 60 * 60000;
|
|
298
|
+
const CHOICES_MAX = 6;
|
|
299
|
+
const localDayOf = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
300
|
+
const startOf = (e) => Date.parse(e && (e.at || e.start));
|
|
301
|
+
const endOf = (e) => { const s = startOf(e); const t = Date.parse(e && e.end); return Number.isFinite(t) && t > s ? t : s + DEFAULT_LEN_MS; };
|
|
302
|
+
/** Every text of an event that may carry the Meet address: its links, and the fields a calendar reader may fill. */
|
|
303
|
+
const meetTextsOf = (e) => [].concat((Array.isArray(e.links) ? e.links : []).map((l) => (l && typeof l === 'object' ? l.url : l)), e.hangoutLink, e.hangout_link, e.meet_url, e.conference, e.location)
|
|
304
|
+
.filter((x) => typeof x === 'string').map((x) => x.toLowerCase());
|
|
305
|
+
/** The tab title of a Meet call without its "Meet - " lead, in a form two titles can be compared in. A bare Meet code is no title. */
|
|
306
|
+
function meetTitle(title) {
|
|
307
|
+
const raw = String(title == null ? '' : title).replace(/^\s*meet\s*[-\u2013\u2014]\s*/i, '').trim();
|
|
308
|
+
return /^[a-z]{3}-[a-z]{4}-[a-z]{3}$/i.test(raw) ? '' : norm(raw);
|
|
309
|
+
}
|
|
310
|
+
const titleFits = (a, b) => !!a && !!b && (a === b || (Math.min(a.length, b.length) >= 4 && (a.includes(b) || b.includes(a))));
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Which meeting on the calendar is the Google Meet call the browser shows. In order: the meeting whose links carry the Meet code, the meeting whose
|
|
314
|
+
* title is the tab's title, the meeting under way now (from 10 minutes before the start to 10 minutes after the end; of several, the one that
|
|
315
|
+
* started last). Only today's meetings, and any meeting under way now, are looked at. Several that fit one rule: the one under way, else the nearest start.
|
|
316
|
+
* When none fits: { ok:false, status:404, error, choices } with today's meetings nearest now (at most 6, in time order). Pure.
|
|
317
|
+
*/
|
|
318
|
+
function pickNow(events, { now = Date.now(), meet = '', title = '' } = {}) {
|
|
319
|
+
const day = localDayOf(now);
|
|
320
|
+
const pool = (events || []).filter((e) => e && e.id && Number.isFinite(startOf(e)) && (localDayOf(startOf(e)) === day || (startOf(e) <= now && now <= endOf(e))));
|
|
321
|
+
const near = (e) => (now < startOf(e) ? startOf(e) - now : now > endOf(e) ? now - endOf(e) : 0);
|
|
322
|
+
const best = (list) => list.slice().sort((a, b) => near(a) - near(b) || startOf(b) - startOf(a))[0] || null;
|
|
323
|
+
const hit = (e, by) => ({ ok: true, by, date: String(e.at || e.start || '').slice(0, 10), event: { id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null } });
|
|
324
|
+
const code = (String(meet || '').match(MEET_CODE) || [''])[0].toLowerCase();
|
|
325
|
+
if (code) { const e = best(pool.filter((x) => meetTextsOf(x).some((t) => t.includes(code)))); if (e) return hit(e, 'meet'); }
|
|
326
|
+
const want = meetTitle(title);
|
|
327
|
+
if (want) {
|
|
328
|
+
const exact = pool.filter((x) => norm(x.title) === want);
|
|
329
|
+
const e = best(exact.length ? exact : pool.filter((x) => titleFits(norm(x.title), want)));
|
|
330
|
+
if (e) return hit(e, 'title');
|
|
331
|
+
}
|
|
332
|
+
const under = pool.filter((e) => startOf(e) - NOW_SLACK_MS <= now && now <= endOf(e) + NOW_SLACK_MS);
|
|
333
|
+
if (under.length) return hit(under.slice().sort((a, b) => startOf(b) - startOf(a))[0], 'time');
|
|
334
|
+
const choices = pool.slice().sort((a, b) => near(a) - near(b) || startOf(a) - startOf(b)).slice(0, CHOICES_MAX).sort((a, b) => startOf(a) - startOf(b))
|
|
335
|
+
.map((e) => ({ id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null }));
|
|
336
|
+
return { ok: false, status: 404, error: 'No meeting on the calendar fits this call.', choices };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = { CATS, REF_TYPES, MIN_GAP_MS, TAIL_MINUTES, TAIL_MAX, topicsFrom, tailOf, capturedFrom, companionPrompt, parseCompanion, foldState, outLine, moveLine, shouldRun, pickNow, meetTitle, norm, str, off };
|
|
@@ -33,6 +33,7 @@ const PROVIDERS = Object.freeze([
|
|
|
33
33
|
'initiative-needs', // Run 2 (2C): day one, agent proposals, passed check-by (kind 7); a record with no anchor (kind 8)
|
|
34
34
|
'work-check-needs', // Run 4 (4D): a person's item in Review, a batched Release (kind 4)
|
|
35
35
|
'report-needs', // Run 5 (5C): a report to issue (kind 1), cycle rows (kind 7), a schedule left no output (kind 8)
|
|
36
|
+
'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
|
|
36
37
|
]);
|
|
37
38
|
/** Kinds that already mean something in the needs queue. A provider may not reuse one. */
|
|
38
39
|
const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);
|
|
@@ -53,6 +53,12 @@ const VERBS = Object.freeze({
|
|
|
53
53
|
// Not a pm-os-absorb verb (run: null): tracker-read-freshness added it through the same table.
|
|
54
54
|
tracker: Object.freeze({ module: 'tracker-cli.js', run: null, guide: 'data-freshness.md',
|
|
55
55
|
help: 'pmos tracker done|status — record when the tracker-refresh skill finished a read of this repository (pmos tracker help)' }),
|
|
56
|
+
// Not a pm-os-absorb verb (run: null): c3-meet-extension added it through the same table.
|
|
57
|
+
extension: Object.freeze({ module: 'extension-cli.js', run: null, guide: 'console-calendar.md',
|
|
58
|
+
help: 'pmos extension — the folder of the Chrome extension for Google Meet, and the three steps to load it' }),
|
|
59
|
+
// Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
|
|
60
|
+
driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
|
|
61
|
+
help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
|
|
56
62
|
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
57
63
|
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
58
64
|
});
|
|
@@ -68,6 +68,7 @@ const MODULES = Object.freeze([
|
|
|
68
68
|
'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
|
|
69
69
|
'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
|
|
70
70
|
'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
|
|
71
|
+
'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
|
|
71
72
|
]);
|
|
72
73
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
73
74
|
const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
|
|
@@ -776,6 +776,16 @@ async function handle(req, res, url, ctx) {
|
|
|
776
776
|
return true;
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
+
/** The PM's move of one initiative, by the same function POST /api/boards/move runs (initiative initiative-driver: Keep on a
|
|
780
|
+
* driver proposal). `g` is the guard's answer ({ ok, actor }); nothing is chosen by the caller. Answers { status, body } with the
|
|
781
|
+
* body the route would send ({ ok, row, warnings } or { ok: false, error }); the route's validation and warnings apply. */
|
|
782
|
+
function move(ctx, body, g) {
|
|
783
|
+
let out = { status: 500, body: { ok: false, error: 'the move was not answered' } };
|
|
784
|
+
const cap = { ...ctx, json: (_res, obj, code) => { out = { status: code || 200, body: obj }; } };
|
|
785
|
+
WRITES['/api/boards/move'](cap, null, body, g);
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
|
|
779
789
|
/** The board of each initiative of a workspace, for other routes (Reports): { boards: [{ id, title }], of(id) → board id }.
|
|
780
790
|
* Rows still being read fall back to the ledger's assignment, else the default board. */
|
|
781
791
|
function boardIndex(ctx, w) {
|
|
@@ -836,4 +846,4 @@ function meetingBoards(ctx) {
|
|
|
836
846
|
return { list, detail };
|
|
837
847
|
}
|
|
838
848
|
|
|
839
|
-
module.exports = { prefixes: ['/api/boards'], handle, boardIndex, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|
|
849
|
+
module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-driver.js — the Initiative Driver's routes (initiative initiative-driver, PRD sections 4 and 5).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/driver/strip?ws&target_kind&id → { ok, ws, target, mode, board, paused, budget, running, last, blocker, proposals }
|
|
5
|
+
* POST /api/driver/keep { ws, proposal } → { ok, proposal, wrote, warnings }
|
|
6
|
+
* POST /api/driver/return { ws, proposal, note } → { ok, proposal } the note is required; it wakes a run
|
|
7
|
+
* POST /api/driver/drop { ws, proposal, note? } → { ok, proposal }
|
|
8
|
+
* POST /api/driver/pause { ws, target_kind, id } → { ok, paused: true, target }
|
|
9
|
+
* POST /api/driver/resume { ws, target_kind, id } → { ok, paused: false, target }
|
|
10
|
+
* POST /api/driver/mode { ws, board?, mode } → { ok, board, mode } board omitted: every board
|
|
11
|
+
*
|
|
12
|
+
* The strip is the data of the Agent strip on an initiative's Overview and of the one-line version on a Work task. Its
|
|
13
|
+
* `proposals` are the open ones; `by` is the page name (`agent:sess-<n>`), never the session token. `running` is a run that has
|
|
14
|
+
* started and not ended; `last` is the most recent run that ended; `blocker` is the sweep's latest finding of today.
|
|
15
|
+
*
|
|
16
|
+
* WHO WRITES. Every POST passes pm-guard requirePagePress, and a request that carries an agent session header is refused
|
|
17
|
+
* first. `by` of every line is the guard's actor. Keep writes through the existing writers (lib/driver-decide.js): a problem
|
|
18
|
+
* into the stage tools, a move through the board move route (lib/routes-boards.js move), a Work next step into the Work items.
|
|
19
|
+
* State: <state folder>/driver.jsonl, one per repository (lib/driver.js). Nothing here sends anything outward.
|
|
20
|
+
*/
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const guard = require('./pm-guard.js');
|
|
23
|
+
const actorLib = require('./actor.js');
|
|
24
|
+
const D = require('./driver.js');
|
|
25
|
+
const decideLib = require('./driver-decide.js');
|
|
26
|
+
const stageTools = require('./stage-tools.js');
|
|
27
|
+
|
|
28
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
29
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
30
|
+
const POSTS = ['keep', 'return', 'drop', 'pause', 'resume', 'mode'];
|
|
31
|
+
|
|
32
|
+
function wsOf(ctx, id) {
|
|
33
|
+
const w = ctx.getWs(id || '');
|
|
34
|
+
return w && (!id || w.id === id) ? w : null;
|
|
35
|
+
}
|
|
36
|
+
function ledgerOf(ctx, w) {
|
|
37
|
+
const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
38
|
+
return D.ledgerFile(d);
|
|
39
|
+
}
|
|
40
|
+
const runtimeOf = (ctx) => ctx.runtimeDir || require('./state-root.js').defaultRoot();
|
|
41
|
+
|
|
42
|
+
/** The board a target's mode is read from: the board of its latest run, else Work's own, else every board. */
|
|
43
|
+
function boardOfTarget(state, target) {
|
|
44
|
+
if (target.kind === 'work') return 'work';
|
|
45
|
+
const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === D.targetKey(target) && r.board);
|
|
46
|
+
return runs.length ? runs[runs.length - 1].board : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripOf(state, target, { now = Date.now() } = {}) {
|
|
50
|
+
const key = D.targetKey(target);
|
|
51
|
+
const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === key);
|
|
52
|
+
const running = runs.filter((r) => !r.end).pop() || null;
|
|
53
|
+
const ended = runs.filter((r) => r.end).pop() || null;
|
|
54
|
+
const board = boardOfTarget(state, target);
|
|
55
|
+
const day = D.localDay(now);
|
|
56
|
+
const blocker = state.blockers.filter((b) => b.target && D.targetKey(b.target) === key && b.day === day).pop() || null;
|
|
57
|
+
const open = D.openProposals(state, target);
|
|
58
|
+
return {
|
|
59
|
+
target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
|
|
60
|
+
budget: { used: D.runsOnDay(state, target, day), limit: target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay, concurrent: D.BUDGET.concurrent },
|
|
61
|
+
running: running ? { run: running.run, since: running.start } : null,
|
|
62
|
+
last: ended ? { run: ended.run, at: ended.end, ok: ended.ok, reason: ended.reason, proposals: [...state.proposals.values()].filter((p) => p.run === ended.run).length } : null,
|
|
63
|
+
blocker: blocker ? { code: blocker.code, text: blocker.text, owner: blocker.owner, day: blocker.day } : null,
|
|
64
|
+
proposals: open.map((p) => ({ id: p.id, what: p.what, data: p.data, why: p.why, sources: p.sources, run: p.run, by: actorLib.pageName(p.by), at: p.at })),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handle(req, res, url, ctx) {
|
|
69
|
+
const p = url.pathname;
|
|
70
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
71
|
+
const sub = p.replace(/^\/api\/driver\/?/, '');
|
|
72
|
+
if (sub !== 'strip' && !POSTS.includes(sub)) return send(bad(404, 'no such driver route'));
|
|
73
|
+
|
|
74
|
+
if (sub === 'strip') {
|
|
75
|
+
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
76
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
77
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
78
|
+
const t = D.checkTarget({ kind: text(url.searchParams.get('target_kind')), ws: w.id, id: text(url.searchParams.get('id')) });
|
|
79
|
+
if (!t.ok) return send(bad(400, t.error));
|
|
80
|
+
const file = ledgerOf(ctx, w);
|
|
81
|
+
const state = D.fold(file ? D.readLines(file) : []);
|
|
82
|
+
return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target) } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
86
|
+
const read = await ctx.readJson(req);
|
|
87
|
+
if (!read.ok) return send(bad(400, 'the body is not JSON'));
|
|
88
|
+
const body = read.body;
|
|
89
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot keep, send back, drop, pause or change the mode of the driver'));
|
|
90
|
+
const g = guard.requirePagePress(req, body);
|
|
91
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
92
|
+
const w = wsOf(ctx, text(body.ws));
|
|
93
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
94
|
+
const file = ledgerOf(ctx, w);
|
|
95
|
+
if (!file) return send(bad(409, 'this repository has no state folder (pmos/state, state or .pmos), so the driver has nowhere to keep its ledger'));
|
|
96
|
+
const refresh = () => { try { if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id); } catch { /* the next poll recounts */ } };
|
|
97
|
+
|
|
98
|
+
if (sub === 'keep' || sub === 'return' || sub === 'drop') {
|
|
99
|
+
const proposal = text(body.proposal);
|
|
100
|
+
if (!D.PROPOSAL_RE.test(proposal)) return send(bad(400, 'proposal must be a driver proposal id'));
|
|
101
|
+
const stateDir = ctx.stateDirFor(w);
|
|
102
|
+
const dctx = {
|
|
103
|
+
stageFile: path.join(stateDir, stageTools.LEDGER),
|
|
104
|
+
itemsDir: path.join(runtimeOf(ctx), 'pm-items'),
|
|
105
|
+
correctionsFile: path.join(runtimeOf(ctx), 'corrections.jsonl'),
|
|
106
|
+
// The board move route's own function: its validation and its warnings apply, and `by` is the PM.
|
|
107
|
+
move: async (pr) => {
|
|
108
|
+
const routesBoards = require('./routes-boards.js');
|
|
109
|
+
let board = pr.data && pr.data.board;
|
|
110
|
+
if (!board) { try { board = routesBoards.boardIndex(ctx, w).of(pr.target.id); } catch { board = null; } }
|
|
111
|
+
return routesBoards.move(ctx, { ws: w.id, board: board || w.id, id: pr.target.id, to: pr.data.to, why: (pr.why || '').slice(0, 500) }, g);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const r = await decideLib.decide(sub, { file, proposal, note: body.note == null ? undefined : text(body.note), actor: g.actor, ctx: dctx });
|
|
115
|
+
if (!r.ok) return send(bad(r.status, r.error));
|
|
116
|
+
refresh();
|
|
117
|
+
return send({ status: 200, body: { ok: true, proposal: { ...r.proposal, by: actorLib.pageName(r.proposal.by) }, wrote: r.wrote, warnings: r.warnings } });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sub === 'pause' || sub === 'resume') {
|
|
121
|
+
const t = D.checkTarget({ kind: text(body.target_kind), ws: w.id, id: text(body.id) });
|
|
122
|
+
if (!t.ok) return send(bad(400, t.error));
|
|
123
|
+
const r = decideLib.setPause(file, sub, t.target, g.actor);
|
|
124
|
+
if (!r.ok) return send(bad(r.status, r.error));
|
|
125
|
+
refresh();
|
|
126
|
+
return send({ status: 200, body: { ok: true, paused: r.paused, target: r.target } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const r = decideLib.setMode(file, body.board == null || body.board === '' ? '*' : text(body.board), text(body.mode), g.actor);
|
|
130
|
+
if (!r.ok) return send(bad(r.status, r.error));
|
|
131
|
+
refresh();
|
|
132
|
+
return send({ status: 200, body: { ok: true, board: r.board, mode: r.mode } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { prefixes: ['/api/driver'], handle, stripOf, boardOfTarget };
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* minutes of that note's transcript with who said them, the decisions, actions and questions so far, and the companion's state.
|
|
8
8
|
* An unknown meeting is a 404. The read also starts the companion when lib/meeting-focus.js shouldRun says it may: the note
|
|
9
9
|
* records, new lines arrived, 40 s passed, no run is under way, PMOS_FOCUS_AGENT is not off. The run is fire and forget.
|
|
10
|
+
* GET /api/meeting-focus/now?meet=<abc-defg-hij>&title=<Meet tab title> (c3-meet-extension)
|
|
11
|
+
* → { ok, event: { id, title, at, end }, date, by: 'meet'|'title'|'time' }, or a 404 { ok:false, error, choices:[{ id, title, at, end }] }
|
|
12
|
+
* Which meeting a Google Meet call is: the one whose links carry the Meet code, else the one titled like the tab, else the one under way now
|
|
13
|
+
* (lib/meeting-focus.js pickNow). It reads the same calendar as the read above and writes nothing.
|
|
10
14
|
* POST /api/meeting-focus/topic { event | note, date?, action: 'set'|'add'|'park', topic } page press; the PM's correction, appended to the state
|
|
11
15
|
* POST /api/meeting-focus/ask { event, note?, date?, question } page press; one headless read-only run with the PM's question
|
|
12
16
|
* GET /api/meeting-focus/ask?id= → { ok, id, state: working | answered | failed, error? } the answer itself comes back as a note in GET /api/meeting-focus
|
|
@@ -175,6 +179,17 @@ async function read(q, ctx) {
|
|
|
175
179
|
} };
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/* ── GET /api/meeting-focus/now ───────────────────────────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
function nowRoute(q, ctx) {
|
|
185
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
186
|
+
let events = [];
|
|
187
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: nowMs }); } catch { events = []; }
|
|
188
|
+
const r = F.pickNow(events, { now: nowMs, meet: F.str(q && q.meet, 40), title: F.str(q && q.title, 300) });
|
|
189
|
+
if (!r.ok) return fail(404, r.error, { choices: r.choices });
|
|
190
|
+
return { status: 200, body: { ok: true, event: r.event, date: r.date, by: r.by } };
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
/* ── POST /api/meeting-focus/topic ────────────────────────────────────────────────────────────────────────────────── */
|
|
179
194
|
|
|
180
195
|
async function topic(req, body, ctx) {
|
|
@@ -272,13 +287,14 @@ const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true;
|
|
|
272
287
|
|
|
273
288
|
module.exports = {
|
|
274
289
|
prefixes: ['/api/meeting-focus'],
|
|
275
|
-
read, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
|
|
290
|
+
read, now: nowRoute, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
|
|
276
291
|
handle: async (req, res, url, ctx) => {
|
|
277
292
|
const p = url.pathname;
|
|
278
293
|
const wsId = url.searchParams.get('ws');
|
|
279
294
|
const w = ctx.getWs ? ctx.getWs(wsId) : null;
|
|
280
295
|
const base = { runtimeDir: ctx.runtimeDir, spawnSession: ctx.spawnSession, w, getWs: ctx.getWs, json: ctx.json };
|
|
281
296
|
if (req.method === 'GET' && p === '/api/meeting-focus') return respond(ctx, res, await read(Object.fromEntries(url.searchParams), base));
|
|
297
|
+
if (req.method === 'GET' && p === '/api/meeting-focus/now') return respond(ctx, res, nowRoute(Object.fromEntries(url.searchParams), base));
|
|
282
298
|
if (req.method === 'GET' && p === '/api/meeting-focus/ask') return respond(ctx, res, askState(Object.fromEntries(url.searchParams), base));
|
|
283
299
|
const fn = req.method === 'POST' && { '/api/meeting-focus/topic': topic, '/api/meeting-focus/ask': ask }[p];
|
|
284
300
|
if (!fn) return false;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.168.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.168.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* c3-demo.js passes GET /api/meeting-focus, GET /api/meeting-focus/ask and the two POSTs (topic, ask) here. The content is the approved
|
|
3
3
|
* mockup's (version 7): the Partner API rate-limit review at 18:24 in, with its six topics (one added from the talk), the gist and its four
|
|
4
4
|
* context chips, the room's last lines, three PMOS notes and two it is holding, the captured items and a Google Slides deck.
|
|
5
|
-
* Whatever meeting the page asks for, the demo answers with this one.
|
|
5
|
+
* Whatever meeting the page asks for, the demo answers with this one. GET /api/meeting-focus/now (the compact page of the Chrome extension) names it too;
|
|
6
|
+
* the Meet code NONE_CODE answers "no meeting fits" with three of the day's meetings as choices, so that path can be walked. A correction (set, add, park) and a question change the answer
|
|
6
7
|
* for the rest of the page's life; nothing is saved and nothing reaches the server. Real mode never loads this file. */
|
|
7
8
|
|
|
8
9
|
const DAY = '2026-10-08';
|
|
@@ -91,8 +92,17 @@ function view() {
|
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
export const NONE_CODE = 'zzz-zzzz-zzz';
|
|
96
|
+
const CHOICES = [['e1', 'Partner standup', '09:30', '10:00'], ['e2', EVENT.title, '11:00', '12:00'], ['e3', 'Onboarding v2 scope check', '14:00', '14:45']]
|
|
97
|
+
.map(([id, title, from, to]) => ({ id: id + '@' + DAY, title, at: DAY + 'T' + from + ':00', end: DAY + 'T' + to + ':00' }));
|
|
98
|
+
|
|
99
|
+
/** GET /api/meeting-focus, GET /api/meeting-focus/ask?id= and GET /api/meeting-focus/now?meet=&title= */
|
|
95
100
|
export function focusGet(path, url) {
|
|
101
|
+
if (path === '/api/meeting-focus/now') {
|
|
102
|
+
const q = new URL(url, location.origin).searchParams;
|
|
103
|
+
if (q.get('meet') === NONE_CODE) return { ok: false, status: 404, error: 'No meeting on the calendar fits this call.', choices: CHOICES };
|
|
104
|
+
return { ok: true, event: { id: EVENT.id, title: EVENT.title, at: EVENT.at, end: EVENT.end }, date: DAY, by: q.get('meet') ? 'meet' : 'time' };
|
|
105
|
+
}
|
|
96
106
|
if (path === '/api/meeting-focus/ask') {
|
|
97
107
|
const q = new URL(url, location.origin).searchParams;
|
|
98
108
|
const a = S.asks.find((x) => x.id === q.get('id'));
|
|
@@ -52,7 +52,7 @@ function getRoute(path, url) {
|
|
|
52
52
|
switch (path) {
|
|
53
53
|
case '/api/briefing': return D.briefing(url);
|
|
54
54
|
case '/api/calendar-views': return withCalendarPage(D.calendarViews(url));
|
|
55
|
-
case '/api/meeting-focus': case '/api/meeting-focus/ask': return focusGet(path, url);
|
|
55
|
+
case '/api/meeting-focus': case '/api/meeting-focus/ask': case '/api/meeting-focus/now': return focusGet(path, url);
|
|
56
56
|
case '/api/pm-items': return D.pmItems();
|
|
57
57
|
case '/api/runs': return D.runs();
|
|
58
58
|
case '/api/releases': return D.releases();
|
|
@@ -167,3 +167,34 @@
|
|
|
167
167
|
.c3focus .fc-cx > span { display: grid; gap: 1px; overflow: visible; text-align: left; }
|
|
168
168
|
.c3focus .fc-cx > span > b { font-weight: 600; }
|
|
169
169
|
.c3.fc-open { container-type: normal; }
|
|
170
|
+
|
|
171
|
+
/* compact mode (?pop=1, initiative c3-meet-extension): the panel the Chrome extension frames over a Google Meet call. A head, What is being said,
|
|
172
|
+
* the conversation and a foot. Folded to a pill, only the head shows. The look is the mockup's "Over Google Meet" panel. Tokens only. */
|
|
173
|
+
.c3focus.fc-pop { overflow: hidden; }
|
|
174
|
+
.c3focus.fc-pop .fc-page { display: flex; flex-direction: column; gap: 8px; height: 100%; max-width: none; margin: 0; padding: 10px; }
|
|
175
|
+
.c3focus.fc-pop .fc-ph { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: var(--fs-2); flex: 0 0 auto; }
|
|
176
|
+
.c3focus.fc-pop .fc-ph b { font-size: var(--fs-1); color: var(--acc); }
|
|
177
|
+
.c3focus.fc-pop .fc-ph-topic { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
178
|
+
.c3focus.fc-pop .fc-ph .mono.dim { font-variant-numeric: tabular-nums; }
|
|
179
|
+
.c3focus.fc-pop .fc-popbody { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; }
|
|
180
|
+
.c3focus.fc-pop .fc-ph .ib { flex: 0 0 auto; width: 28px; height: 28px; min-width: 0; min-height: 0; padding: 0; }
|
|
181
|
+
.c3focus.fc-pop .fc-bandwrap { flex: 0 0 auto; }
|
|
182
|
+
.c3focus.fc-pop .fc-gist { padding: 8px; border-radius: 8px; }
|
|
183
|
+
.c3focus.fc-pop .fc-gist p { font-size: var(--fs-2); }
|
|
184
|
+
.c3focus.fc-pop .fc-gl { display: block; margin: 0 0 2px; }
|
|
185
|
+
.c3focus.fc-pop .fc-cx { padding: 2px 8px; }
|
|
186
|
+
.c3focus.fc-pop .fc-cx > span { display: block; }
|
|
187
|
+
.c3focus.fc-pop .fc-cx > span > span { display: none; }
|
|
188
|
+
.c3focus.fc-pop .fc-cv { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 200px; max-height: none; border: 0; background: none; border-radius: 0; }
|
|
189
|
+
.c3focus.fc-pop .fc-tk { padding: 4px 2px 6px; }
|
|
190
|
+
.c3focus.fc-pop .fc-s2 { flex: 1 1 auto; padding: 4px 2px; }
|
|
191
|
+
.c3focus.fc-pop .fc-in { padding: 6px 2px 0; }
|
|
192
|
+
.c3focus.fc-pop .fc-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex: 0 0 auto; position: sticky; bottom: 0; padding-top: 4px; background: var(--background); font-size: var(--fs-1); }
|
|
193
|
+
.c3focus.fc-pop .fc-popmsg { display: grid; gap: 6px; margin: 0; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); font-size: var(--fs-2); }
|
|
194
|
+
.c3focus.fc-pop .fc-popmsg p { margin: 0; line-height: 1.5; }
|
|
195
|
+
.c3focus.fc-pop .fc-choices { display: grid; gap: 6px; }
|
|
196
|
+
.c3focus.fc-pop .fc-choices button { justify-content: flex-start; text-align: left; white-space: normal; height: auto; }
|
|
197
|
+
.c3focus.fc-pop .fc-choices .mono { margin-right: 4px; }
|
|
198
|
+
.c3focus.fc-pop:not([data-fc-state="ok"]) .fc-cv, .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-foot { display: none; }
|
|
199
|
+
.c3focus.fc-pop.fc-pill .fc-popbody { display: none; }
|
|
200
|
+
.c3focus.fc-pop.fc-pill .fc-page { padding: 6px 10px; justify-content: center; }
|