create-pmos 0.249.0 → 0.251.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 +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +3 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-light-load-test.js +2 -1
- package/workspace/guide/briefing.md +49 -10
- package/workspace/guide/initiative-driver.md +3 -1
- package/workspace/lib/driver-cli.js +8 -3
- package/workspace/lib/driver-needs.js +23 -2
- package/workspace/lib/driver.js +21 -2
- package/workspace/lib/engine/signals/facts.js +1 -1
- package/workspace/lib/engine/workflow-state.js +3 -0
- package/workspace/lib/routes-brief-page.js +18 -3
- package/workspace/lib/vendor/scripts/workflow-state.js +3 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +4 -1
- package/workspace/public/pm/c3-brief-mentions.js +2 -1
- package/workspace/public/pm/c3-brief-needs.js +110 -41
- package/workspace/public/pm/priority-model.js +236 -0
- package/workspace/public/pm/work-place.js +12 -2
- package/workspace/server.js +23 -3
- package/workspace/skills/initiative-drive.skill +1 -0
- package/workspace/skills/work-unblock.skill +1 -0
- package/workspace/test-files.json +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.251.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@0657606
|
|
2
|
+
assembled: 2026-10-16
|
|
3
3
|
mode: vendor
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T03:26:16Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T03:26:16Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T03:26:16Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
|
@@ -49,7 +49,8 @@ process.on('unhandledRejection', (e) => { console.error(e); fail++; finish(); })
|
|
|
49
49
|
const paths = first.map((g) => g.path);
|
|
50
50
|
|
|
51
51
|
/* R1: the code of the other tabs is not loaded */
|
|
52
|
-
|
|
52
|
+
/* 55 before briefing-prioritization; its one pure module (priority-model.js, the ranking Needs you, the deck, Mentions and Work all share) makes 56 */
|
|
53
|
+
ok('R1: the desktop console loads at most 56 script files on open', scripts.size <= 56, scripts.size + ' files');
|
|
53
54
|
for (const m of ['calendar-views.js', 'c3-initiatives.js', 'c3-reports.js', 'c3-settings.js', 'c3-phone.js', 'c3-doc-sheet.js', 'view-notes.js'])
|
|
54
55
|
ok('R1: ' + m + ' is not loaded on open', !scripts.has(m));
|
|
55
56
|
/* R2: the folded dock reads only the runs */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "The Briefing: today's brief"
|
|
4
|
-
description: "The Briefing page of the console (/): The brief, the headline cards, Today, Needs you, Mentions, Portfolio pulse, and what Done, Not yet and Drop do to an item."
|
|
5
|
-
timestamp: 2026-10-
|
|
4
|
+
description: "The Briefing page of the console (/): The brief, the headline cards, Today, Needs you and how it is ranked, Mentions, Portfolio pulse, and what Done, Not yet and Drop do to an item."
|
|
5
|
+
timestamp: 2026-10-15
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# The Briefing: today's brief
|
|
@@ -31,7 +31,7 @@ The cards come from two places:
|
|
|
31
31
|
- An item of today's morning brief that is worth asking, and an item of today's brief that is for you.
|
|
32
32
|
- **The signal engine.** It reads the initiative records of the repository, for example a spec written after the build.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
The cards take the same order as Needs you (see Needs you, The order): a pinned Critical card first, then the cards that cannot wait, then the rest by score. PMOS adds at most 5 cards from your own data. A card shows only the values that its record holds. When the record has a link, for example a Jira ticket, the case of the card has a link to it. The link opens in a new tab. When no record gives a card, the deck says that nothing needs you.
|
|
35
35
|
|
|
36
36
|
Above the cards, the greeting names the part of the day, for example "Good morning, Wawan". The name is the first word of the git user name on the computer where PMOS runs. When git holds no name, the greeting has none. The part of the day follows the clock. There is no button to change it. When the clock moves to a new part, the line under the greeting and the order of the cards change after you stop using the page for a minute.
|
|
37
37
|
|
|
@@ -64,12 +64,47 @@ A Today row is a reference. It has no button that changes anything.
|
|
|
64
64
|
|
|
65
65
|
## Needs you
|
|
66
66
|
|
|
67
|
-
Needs you lists what only you can do. Its header says how many decisions, how many items are yours, and how many are overdue. A decision row has a **decision** tag. When it cannot be undone it also says **hard to undo**. An item of yours has a **Work** chip: Work is where the item lives.
|
|
67
|
+
Needs you lists what only you can do. Its header says how many decisions, how many items are yours, how many come from the agent, and how many are overdue. A decision row has a **decision** tag. When it cannot be undone it also says **hard to undo**. An item of yours has a **Work** chip: Work is where the item lives.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
### The order
|
|
70
|
+
|
|
71
|
+
Every source goes into one list, and PMOS puts the rows in one order. PMOS does not call a model to do this. The same rows always give the same order.
|
|
72
|
+
|
|
73
|
+
**Do these first** shows the three rows to do first. A row can be here for one of two reasons:
|
|
74
|
+
|
|
75
|
+
- It cannot wait. A signal engine card that is Critical, and a promise to someone outside PMOS that is due or overdue, always come first. Every such row shows, even when there are more than three. The header says how many cannot wait.
|
|
76
|
+
- It scores highest of the rest.
|
|
77
|
+
|
|
78
|
+
**Next up** shows the other rows in score order. It shows five rows. The others are in the fold "below the line".
|
|
79
|
+
|
|
80
|
+
Each row has a line of reasons under its title, for example "Frees 3 agent runs · 2 days old · about 2 min". Every reason is a part of that row's score. A reason that is not true for the row does not show. The last reason is the effort. PMOS estimates the effort from the kind of row, so it always says "about". An agent draft takes about 2 minutes. An item outside PMOS or a card with a suggested move takes about 15 minutes. A title that names a review, a spec or a PRD takes an hour or more.
|
|
81
|
+
|
|
82
|
+
The score uses:
|
|
83
|
+
|
|
84
|
+
- How much the item matters. This is its priority, or the signal engine's number for a card. An initiative that is off track adds to it.
|
|
85
|
+
- How soon it is due. An overdue item is the most urgent.
|
|
86
|
+
- Whether you can undo it.
|
|
87
|
+
- How many agent runs and people wait for it. An agent that waits on your drafts counts here.
|
|
88
|
+
- How long it has waited. After a week the item counts 1.5 times as much, so no row waits for ever.
|
|
89
|
+
- Your flag. A flagged item counts 1.5 times as much.
|
|
90
|
+
- The effort. A short task ranks above a long task of the same score.
|
|
91
|
+
|
|
92
|
+
Three or more rows of one kind on one initiative show as one row that opens to its members.
|
|
93
|
+
|
|
94
|
+
### What is not on the list
|
|
95
|
+
|
|
96
|
+
PMOS does not rank an item that you cannot act on now. A line above the list counts them: "Not on this list: 4 waiting on others · 3 later · 2 someday". The items are not deleted. Press the line to open Work. A flagged item and a Critical card stay on the list when they are waiting or set to later. An item you finished, and a mention you marked read, are gone.
|
|
97
|
+
|
|
98
|
+
A line also says when items have been untouched for a week. Press it to open the Work page, where you can review them.
|
|
99
|
+
|
|
100
|
+
### The rows
|
|
101
|
+
|
|
102
|
+
The rows come from these sources:
|
|
103
|
+
|
|
104
|
+
1. Your own items that are overdue or due today.
|
|
105
|
+
2. Items outside PMOS that wait for you to confirm: talk to someone, meet, call, ask.
|
|
106
|
+
3. Decisions that only you can make.
|
|
107
|
+
4. Drafts and blocks from the agent.
|
|
73
108
|
|
|
74
109
|
The decisions come from these records:
|
|
75
110
|
|
|
@@ -77,7 +112,9 @@ The decisions come from these records:
|
|
|
77
112
|
- A signal engine card that has a suggested move, or is Critical.
|
|
78
113
|
- An item of today's morning brief that is worth asking. The brief was not sure enough to act, so ask first.
|
|
79
114
|
|
|
80
|
-
An item that is in your own items
|
|
115
|
+
An item that is in your own items does not show again as a decision. **Done** and **Later** on a ticket row change the item in your Work list. They do not change Jira.
|
|
116
|
+
|
|
117
|
+
An agent row says how old its oldest draft is and how many runs it holds. When the agent states how sure it is, whether you can undo the move, or how long it takes, the ranking uses that. A fact the agent did not state reads "Not known yet" and counts as neutral.
|
|
81
118
|
|
|
82
119
|
Press a row to open it in place. A row of your own item has three actions. Each one writes to the item itself, the same record the Work page shows.
|
|
83
120
|
|
|
@@ -87,9 +124,11 @@ Press a row to open it in place. A row of your own item has three actions. Each
|
|
|
87
124
|
|
|
88
125
|
PMOS can see evidence that an outside item is done. For example, a meeting with the person is on the calendar. Then the row says "Looks done: a meeting with Ana is booked Tue 10:00. Confirm?" PMOS never marks the item done itself. The item changes only when you press **Done** or **Yes, it is done**.
|
|
89
126
|
|
|
127
|
+
The headline cards, the Mentions list and the Today view of the Work page use the same order.
|
|
128
|
+
|
|
90
129
|
## Mentions
|
|
91
130
|
|
|
92
|
-
Mentions lists the places where someone mentioned you today: Slack, Jira, Confluence and mail. The list comes from today's morning brief. When there is no morning brief for today, Mentions says so.
|
|
131
|
+
Mentions lists the places where someone mentioned you today: Slack, Jira, Confluence and mail. The list comes from today's morning brief. A mention that needs your reply comes before one for your information. Among the replies, the one that has waited longest comes first. When there is no morning brief for today, Mentions says so.
|
|
93
132
|
|
|
94
133
|
The section is quiet. It shows one line that names who waits for your reply, for example "Priya in Slack and Dara in Jira are waiting on your reply." The header says how many need a reply. Press **Show all** to open the list. A tab for each source filters the list. Each row shows the person, the text they wrote, the source and the place, the time, and **needs your reply** or **for your information**. Each row has these actions:
|
|
95
134
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "The Initiative Driver: drafts the agent files for you"
|
|
4
4
|
description: "What the Initiative Driver does when an initiative is created or moved, what stops it, how it files proposals you keep, send back or drop, the board modes, and the pmos driver commands."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-15
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# The Initiative Driver: drafts the agent files for you
|
|
@@ -84,6 +84,8 @@ The agent uses `pmos driver propose` to file a proposal and `pmos driver status`
|
|
|
84
84
|
- `pmos driver pause <initiative|work> <id>` and `pmos driver resume <initiative|work> <id>` stop and restart the runs for one target.
|
|
85
85
|
- `pmos driver mode <off|shadow|approve> [--board <id>]` sets the board mode. Without `--board` it sets every board.
|
|
86
86
|
|
|
87
|
+
When the agent files a proposal, it can also state what it knows, so that the Briefing can rank the row: `--confidence 0..1` (how sure it is), `--recommended "<what it suggests you do>"`, `--reversible true|false` (whether you can undo the move) and `--effort S|M|L` (about 2 minutes, about 15 minutes, an hour or more). Each one is optional. The Briefing shows a missing one as "Not known yet" and does not guess it. The agent states a fact only when it knows it.
|
|
88
|
+
|
|
87
89
|
The ledger is `driver.jsonl` in the state folder of the repository (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.
|
|
88
90
|
|
|
89
91
|
## Related
|
|
@@ -30,7 +30,7 @@ const stateRoot = require('./state-root.js');
|
|
|
30
30
|
const COMMANDS = { driver: ['propose', 'status', 'keep', 'return', 'drop', 'pause', 'resume', 'mode'] };
|
|
31
31
|
const USAGE = [
|
|
32
32
|
'usage: pmos driver propose|status|keep|return|drop|pause|resume|mode — the Initiative Driver',
|
|
33
|
-
' pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>" [--source label=ref …]',
|
|
33
|
+
' pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>" [--source label=ref …] [--confidence 0..1] [--recommended "<what you suggest>"] [--reversible true|false] [--effort S|M|L]',
|
|
34
34
|
' what: problem, evidence, sizing, assumption, stakeholders, move, note, doc (initiative) · next-step, follow-up, note (Work task)',
|
|
35
35
|
' pmos driver status <initiative|work> <id>',
|
|
36
36
|
' pmos driver keep <proposal> PM only',
|
|
@@ -42,7 +42,7 @@ const USAGE = [
|
|
|
42
42
|
'Every claim in a proposal carries a pointer (--source label=ref). The driver sends nothing and edits no record: Keep is the PM\'s.',
|
|
43
43
|
];
|
|
44
44
|
const BOOL = new Set(['json', 'help']);
|
|
45
|
-
const VALUE = new Set(['what', 'file', 'data', 'why', 'source', 'ws', 'note', 'board', 'root']);
|
|
45
|
+
const VALUE = new Set(['what', 'file', 'data', 'why', 'source', 'ws', 'note', 'board', 'root', 'confidence', 'recommended', 'reversible', 'effort']);
|
|
46
46
|
|
|
47
47
|
function parse(argv) {
|
|
48
48
|
const pos = []; const flags = {}; const sources = [];
|
|
@@ -127,7 +127,12 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
127
127
|
if (d.error) return fail(d.error, 2);
|
|
128
128
|
const s = sourcesFrom(sources);
|
|
129
129
|
if (s.error) return fail(s.error, 2);
|
|
130
|
-
const
|
|
130
|
+
const asked = {};
|
|
131
|
+
if (flags.confidence !== undefined) { const n = Number(flags.confidence); asked.confidence = flags.confidence === '' || !Number.isFinite(n) ? NaN : n; }
|
|
132
|
+
if (flags.recommended !== undefined) asked.recommended = flags.recommended;
|
|
133
|
+
if (flags.reversible !== undefined) { if (!/^(true|false)$/i.test(flags.reversible)) return fail('--reversible is true or false', 2); asked.reversible = /^true$/i.test(flags.reversible); }
|
|
134
|
+
if (flags.effort !== undefined) asked.effort = String(flags.effort).toUpperCase();
|
|
135
|
+
const c = D.checkProposal({ ...target(), what: flags.what, data: d.data, why: flags.why, sources: s.sources, ...asked });
|
|
131
136
|
if (!c.ok) return fail(c.error);
|
|
132
137
|
const actor = actorLib.fromCli(who);
|
|
133
138
|
const state = D.fold(D.readLines(file));
|
|
@@ -36,6 +36,27 @@ function titlesOf(w) {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** What the Briefing's ranking needs of one target's open drafts (briefing-prioritization): how old the oldest is, how many wakes the
|
|
40
|
+
* agent could not start while the PM held them, and the facts the agent stated when it asked. A fact nobody stated is absent. */
|
|
41
|
+
function rankFacts(state, g, now) {
|
|
42
|
+
const stamps = g.list.map((p) => Date.parse(p.at)).filter(Number.isFinite);
|
|
43
|
+
const oldest = stamps.length ? Math.min(...stamps) : null;
|
|
44
|
+
const key = D.targetKey(g.target);
|
|
45
|
+
const held = state.wakes.filter((w) => w.target && D.targetKey(w.target) === key && (w.state === 'queued' || w.state === 'refused') && (oldest === null || Date.parse(w.at) >= oldest)).length
|
|
46
|
+
+ state.refusals.filter((r) => r.target && D.targetKey(r.target) === key && (oldest === null || Date.parse(r.at) >= oldest)).reduce((a, r) => a + Math.max(1, (r.wakes || []).length), 0);
|
|
47
|
+
const fs = g.list.map((p) => p.facts).filter(Boolean);
|
|
48
|
+
const facts = {};
|
|
49
|
+
const conf = fs.map((f) => f.confidence).filter((v) => typeof v === 'number');
|
|
50
|
+
if (conf.length) facts.confidence = Math.min(...conf);
|
|
51
|
+
const rec = fs.map((f) => f.recommended).filter(Boolean);
|
|
52
|
+
if (rec.length) facts.recommended = rec[0];
|
|
53
|
+
if (fs.some((f) => f.reversible === false)) facts.reversible = false;
|
|
54
|
+
else if (fs.length && fs.every((f) => f.reversible === true)) facts.reversible = true;
|
|
55
|
+
const eff = fs.map((f) => f.effort).filter((v) => v === 'S' || v === 'M' || v === 'L');
|
|
56
|
+
if (eff.length) facts.effort = ['S', 'M', 'L'][Math.max(...eff.map((e) => ['S', 'M', 'L'].indexOf(e)))];
|
|
57
|
+
return { age_days: oldest === null ? null : Math.max(0, Math.floor((now - oldest) / D.DAY)), blocked_runs: held, drafts: g.list.length, facts: Object.keys(facts).length ? facts : null };
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function rowsOf(state, { titleOf = () => null, now = Date.now(), ledgerRel = 'pmos/state/driver.jsonl' } = {}) {
|
|
40
61
|
const rows = [];
|
|
41
62
|
const open = new Map();
|
|
@@ -57,7 +78,7 @@ function rowsOf(state, { titleOf = () => null, now = Date.now(), ledgerRel = 'pm
|
|
|
57
78
|
const title = titleOf(g.target) || g.target.id;
|
|
58
79
|
const whats = [...new Set(g.list.map((p) => p.what))].join(', ');
|
|
59
80
|
rows.push({
|
|
60
|
-
run_id: `${NAME}:drafts:${k}`, kind: 'driver-drafts', initiative_id: g.target.kind === 'initiative' ? g.target.id : null,
|
|
81
|
+
run_id: `${NAME}:drafts:${k}`, kind: 'driver-drafts', initiative_id: g.target.kind === 'initiative' ? g.target.id : null, rank: rankFacts(state, g, now),
|
|
61
82
|
question: `Agent has ${plural(g.list.length, 'draft', 'drafts')} on ${title}`, doc: null,
|
|
62
83
|
today: { kind: 7, source: NAME, cause: `The agent filed ${plural(g.list.length, 'draft', 'drafts')} for ${KIND_WORD[g.target.kind]}: ${whats}`, evidence: ledgerRel, act: { label: 'Review', cli: null } },
|
|
63
84
|
});
|
|
@@ -75,7 +96,7 @@ function rowsOf(state, { titleOf = () => null, now = Date.now(), ledgerRel = 'pm
|
|
|
75
96
|
const days = Math.max(1, Math.floor((now - oldest) / D.DAY));
|
|
76
97
|
const title = titleOf(g.target) || g.target.id;
|
|
77
98
|
rows.push({
|
|
78
|
-
run_id: `${NAME}:blocked:${k}`, kind: 'driver-blocked', initiative_id: g.target.kind === 'initiative' ? g.target.id : null,
|
|
99
|
+
run_id: `${NAME}:blocked:${k}`, kind: 'driver-blocked', initiative_id: g.target.kind === 'initiative' ? g.target.id : null, rank: rankFacts(state, g, now),
|
|
79
100
|
question: `${title} is waiting on you: the drafts are ${plural(days, 'day', 'days')} old`, doc: null,
|
|
80
101
|
today: { kind: 7, source: NAME, cause: 'The agent starts no new run on this until you keep, send back or drop its drafts', evidence: ledgerRel, act: { label: 'Review', cli: null } },
|
|
81
102
|
});
|
package/workspace/lib/driver.js
CHANGED
|
@@ -59,6 +59,7 @@ const DEFAULT_MODE = 'approve';
|
|
|
59
59
|
const WAKES = Object.freeze(['created', 'moved', 'answered', 'stuck']);
|
|
60
60
|
const EVENTS = Object.freeze(['proposal', 'keep', 'return', 'drop', 'correction', 'pause', 'resume', 'mode', 'wake', 'run-start', 'run-end', 'refused', 'blocker', 'epoch']);
|
|
61
61
|
const BUDGET = Object.freeze({ concurrent: 3 });
|
|
62
|
+
const EFFORTS = Object.freeze(['S', 'M', 'L']);
|
|
62
63
|
const DAY = 86400000;
|
|
63
64
|
const PM_WAIT_MS = DAY; // an open proposal older than this makes the PM the blocker
|
|
64
65
|
const SWEEP_EVERY_MS = 30 * 60 * 1000; // the sweep's cadence inside the day-check window
|
|
@@ -177,7 +178,25 @@ function checkProposal(input) {
|
|
|
177
178
|
if (data.due != null && !(typeof data.due === 'string' && DATE_RE.test(data.due))) return { ok: false, error: 'data.due is YYYY-MM-DD' };
|
|
178
179
|
data = { text: data.text.trim(), ...(data.due ? { due: data.due } : {}) };
|
|
179
180
|
}
|
|
180
|
-
|
|
181
|
+
/* Optional facts the agent knows when it asks and the Briefing's ranking needs (briefing-prioritization). A value that is present must be well-formed; one that is absent stays absent. */
|
|
182
|
+
const facts = {};
|
|
183
|
+
if (i.confidence !== undefined && i.confidence !== null) {
|
|
184
|
+
if (typeof i.confidence !== 'number' || !(i.confidence >= 0 && i.confidence <= 1)) return { ok: false, error: 'confidence is a number from 0 to 1' };
|
|
185
|
+
facts.confidence = i.confidence;
|
|
186
|
+
}
|
|
187
|
+
if (i.recommended !== undefined && i.recommended !== null) {
|
|
188
|
+
if (!oneLine(i.recommended, 200)) return { ok: false, error: 'recommended is one line of 1 to 200 characters: what you suggest the PM does' };
|
|
189
|
+
facts.recommended = i.recommended.trim();
|
|
190
|
+
}
|
|
191
|
+
if (i.reversible !== undefined && i.reversible !== null) {
|
|
192
|
+
if (typeof i.reversible !== 'boolean') return { ok: false, error: 'reversible is true or false' };
|
|
193
|
+
facts.reversible = i.reversible;
|
|
194
|
+
}
|
|
195
|
+
if (i.effort !== undefined && i.effort !== null) {
|
|
196
|
+
if (!EFFORTS.includes(i.effort)) return { ok: false, error: `effort is one of ${EFFORTS.join(', ')}: S about 2 minutes, M about 15, L an hour or more` };
|
|
197
|
+
facts.effort = i.effort;
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, proposal: { target: t.target, what: i.what, data, why: i.why.trim(), sources, ...(Object.keys(facts).length ? { facts } : {}) } };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
// ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -197,7 +216,7 @@ function fold(lines) {
|
|
|
197
216
|
case 'proposal': {
|
|
198
217
|
if (!PROPOSAL_RE.test(String(l.id)) || s.proposals.has(l.id) || !checkTarget(l.target).ok) break;
|
|
199
218
|
s.proposals.set(l.id, { id: l.id, target: { kind: l.target.kind, ws: l.target.ws, id: l.target.id }, what: l.what, data: l.data, why: l.why,
|
|
200
|
-
sources: Array.isArray(l.sources) ? l.sources : [], run: l.run || null, by: l.by || null, at: l.at || null, state: 'open', note: null, decided_at: null, decided_by: null });
|
|
219
|
+
sources: Array.isArray(l.sources) ? l.sources : [], facts: isObj(l.facts) ? l.facts : null, run: l.run || null, by: l.by || null, at: l.at || null, state: 'open', note: null, decided_at: null, decided_by: null });
|
|
201
220
|
s.order.push(l.id);
|
|
202
221
|
break;
|
|
203
222
|
}
|
|
@@ -92,7 +92,7 @@ function readRepoFacts(root, opts) {
|
|
|
92
92
|
const inis = [];
|
|
93
93
|
for (const id of ws.listInitiatives(base)) {
|
|
94
94
|
let w;
|
|
95
|
-
try { w = ws.computeWorkflow(id, range ? { root: base, range } : { root: base }); } catch { continue; }
|
|
95
|
+
try { w = ws.computeWorkflow(id, range ? { root: base, range, skipLastActivity: true } : { root: base, skipLastActivity: true }); } catch { continue; }
|
|
96
96
|
if (!w || !w.ok) continue;
|
|
97
97
|
const fm = readFrontmatter(path.join(base, w.record || `planning/initiatives/${id}.md`));
|
|
98
98
|
inis.push({
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -110,6 +110,20 @@ function evidenceFor(item, events, now) {
|
|
|
110
110
|
return null;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** The items the PM cannot act on now, counted by why (briefing-prioritization, stage 1): waiting on someone, set to a later day, parked as Someday.
|
|
114
|
+
* The ranking leaves them out of Needs you and the header says how many there are. Done items are not counted. */
|
|
115
|
+
function heldOf(items, now) {
|
|
116
|
+
const todayIso = localDate(now);
|
|
117
|
+
const out = { waiting: 0, later: 0, someday: 0 };
|
|
118
|
+
for (const i of items || []) {
|
|
119
|
+
if (!i || i.done) continue;
|
|
120
|
+
if (i.parked) out.someday += 1;
|
|
121
|
+
else if (i.waiting_on) out.waiting += 1;
|
|
122
|
+
else if (i.list_override === 'later' || (i.defer && i.defer > todayIso)) out.later += 1;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function needsOf(items, events, cards, now, deps = {}) {
|
|
114
128
|
const todayIso = localDate(now);
|
|
115
129
|
const todayDate = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
@@ -117,7 +131,8 @@ function needsOf(items, events, cards, now, deps = {}) {
|
|
|
117
131
|
const late = []; const dueToday = []; const offline = [];
|
|
118
132
|
for (const i of items) {
|
|
119
133
|
if (!i || i.done || deferred(i, now, todayIso)) continue;
|
|
120
|
-
const base = { id: i.id, headline: i.title, due: i.due || null, priority: i.priority || null, person: i.person || null
|
|
134
|
+
const base = { id: i.id, headline: i.title, due: i.due || null, priority: i.priority || null, person: i.person || null, flagged: !!i.flagged,
|
|
135
|
+
days_to_due: i.due && /^\d{4}-\d{2}-\d{2}$/.test(String(i.due)) ? Math.round((Date.parse(i.due + 'T00:00:00Z') - Date.parse(todayIso + 'T00:00:00Z')) / DAY) : null };
|
|
121
136
|
if (i.kind === 'offline') {
|
|
122
137
|
const late0 = i.due && i.due < todayIso ? daysBetween(i.due, todayIso) : 0;
|
|
123
138
|
offline.push({ key: 'item:' + i.id, kind: 'offline', ...base, days_late: late0, offline: true, evidence: evidenceFor(i, events, now) });
|
|
@@ -181,7 +196,7 @@ function build(query, deps = {}) {
|
|
|
181
196
|
let now = q.now ? new Date(q.now) : (deps.now ? new Date(deps.now) : new Date());
|
|
182
197
|
if (Number.isNaN(now.getTime())) now = new Date();
|
|
183
198
|
const events = deps.events || []; const items = deps.items || [];
|
|
184
|
-
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps),
|
|
199
|
+
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps), held: heldOf(items, now), review: typeof pmItems.reviewQueue === 'function' ? pmItems.reviewQueue(items, now).length : 0,
|
|
185
200
|
mentions: mentionsOf(deps.brief || null, deps.itemLines || [], readMarks(deps.readLines), localDate(now)) };
|
|
186
201
|
if (deps.decisionsError) body.decisions_error = deps.decisionsError;
|
|
187
202
|
return { status: 200, body };
|
|
@@ -212,7 +227,7 @@ function firstName(cwd) {
|
|
|
212
227
|
module.exports = {
|
|
213
228
|
prefixes: ['/api/brief-page'],
|
|
214
229
|
MENTION_ID,
|
|
215
|
-
build, todayAgenda, needsOf, evidenceFor, deferred, firstName, notesAdapter, mentionsOf, readMarks,
|
|
230
|
+
build, todayAgenda, needsOf, heldOf, evidenceFor, deferred, firstName, notesAdapter, mentionsOf, readMarks,
|
|
216
231
|
handle: async (req, res, url, ctx) => {
|
|
217
232
|
if (url.pathname !== '/api/brief-page') return false;
|
|
218
233
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.251.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.251.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { el } from './dom.js';
|
|
14
14
|
import { createIdleGate, watchIdle, applyDayPart } from './c3-daypart.js';
|
|
15
15
|
import { TAG_STYLE, PART_STYLE, partByKey, partFor, cardView, nextCards, swipeDir, sizeFor, caseModelOf, rawOption } from './briefing-model.js';
|
|
16
|
+
import { rankCards } from './priority-model.js';
|
|
16
17
|
import { BX, caseFile, toast } from './c3-brief.js';
|
|
17
18
|
import { ensureC3Css, c3ic, c3ring, c3Phone } from './pm-work-view.js';
|
|
18
19
|
import { listenButton } from './shell-readaloud.js';
|
|
@@ -333,7 +334,9 @@ function readLens(lens, zoom, scope) {
|
|
|
333
334
|
async function load(S, lens) {
|
|
334
335
|
if (S.data[lens] !== undefined) return;
|
|
335
336
|
S.data[lens] = null;
|
|
336
|
-
|
|
337
|
+
const got = await readLens(lens, S.zoom, S.ctx && S.ctx.scope);
|
|
338
|
+
/* the deck takes the order the Needs you rows take (priority-model.js): the pinned Critical card, the hard lane, then by score */
|
|
339
|
+
S.data[lens] = got && got.ok && Array.isArray(got.cards) ? { ...got, cards: rankCards(got.cards) } : got;
|
|
337
340
|
if (S.dead) return; // a deck built before its host is attached still draws: it only touches its own root
|
|
338
341
|
if (lens === S.lens) draw(S); // a lens the clock has since left stays read for later, undrawn
|
|
339
342
|
}
|
|
@@ -17,6 +17,7 @@ import { onBriefPage, loadBriefPage } from './c3-brief-data.js';
|
|
|
17
17
|
import { button, tabs, SEL, ensureComponentsCss } from './primitives.js';
|
|
18
18
|
import { ensureSprite } from './icons.js';
|
|
19
19
|
import { srcName, waitingMentions } from './c3-brief-summary.js';
|
|
20
|
+
import { rankMentions } from './priority-model.js';
|
|
20
21
|
|
|
21
22
|
const p2 = (n) => String(n).padStart(2, '0');
|
|
22
23
|
const first = (s) => String(s || '').trim().split(/\s+/)[0];
|
|
@@ -124,7 +125,7 @@ export function mentionsSection(S) {
|
|
|
124
125
|
|
|
125
126
|
st.list = undefined;
|
|
126
127
|
onBriefPage((j) => {
|
|
127
|
-
st.list = j && j.ok !== false ? (Array.isArray(j.mentions) ? j.mentions : null) : null;
|
|
128
|
+
st.list = j && j.ok !== false ? (Array.isArray(j.mentions) ? rankMentions(j.mentions) : null) : null; // a reply that is needed first, then the one who has waited longest (priority-model.js)
|
|
128
129
|
st.local.clear();
|
|
129
130
|
draw();
|
|
130
131
|
if (S.tagNeeds) S.tagNeeds(S.needsBox);
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* Drop POST /api/pm-items/remove { id } (Undo: POST /api/pm-items/undo)
|
|
11
11
|
* An outside-PMOS item (talk to, meet, call, ask) takes an optional one-line outcome. When PMOS holds evidence ("a meeting with Ana is
|
|
12
12
|
* booked Tue 10:00") it asks "Looks done: ... Confirm?". It never marks the item done itself: only a press on Done or Confirm does.
|
|
13
|
-
* The own-item rows are built again only when their keys change, so a re-read does not close an open row. An open row is kept in BX.open.
|
|
13
|
+
* The own-item rows are built again only when their keys change, so a re-read does not close an open row. An open row is kept in BX.open.
|
|
14
|
+
*
|
|
15
|
+
* ONE RANKING (initiative briefing-prioritization). The four sources above no longer sit in four lists. Each builds its rows into a pool; paint() asks
|
|
16
|
+
* priority-model.js to rank them together: "Do these first" holds the hard lane (a Critical card, a promise to someone outside that is due) and the
|
|
17
|
+
* best rows up to three, each with the reasons it ranks there; "Next up" holds the rest, folded after five; a line counts what the PM cannot act on now
|
|
18
|
+
* (waiting, later, someday). The ranking reads the rows and writes nothing. */
|
|
14
19
|
import { el } from './dom.js';
|
|
15
20
|
import { c3ic } from './pm-work-view.js';
|
|
16
21
|
import { pmPost } from './http.js';
|
|
@@ -19,9 +24,10 @@ import { needRow, belowLine, BX, toast, hooks } from './c3-brief.js';
|
|
|
19
24
|
import { onBriefing, loadBriefing } from './shell-ticker.js';
|
|
20
25
|
import { cardView, TAG_STYLE } from './briefing-model.js';
|
|
21
26
|
import { onBriefPage } from './c3-brief-data.js';
|
|
22
|
-
import { button, ensureComponentsCss } from './primitives.js';
|
|
27
|
+
import { button, SEL, ensureComponentsCss } from './primitives.js';
|
|
23
28
|
import { loadDriverNeeds, needsRow as driverRow } from './c3-driver.js';
|
|
24
29
|
import { needsLoc } from './c3-core-docs.js';
|
|
30
|
+
import { rank, fromOwn, fromDecision, fromAgent, heldLine, FIRST_N } from './priority-model.js';
|
|
25
31
|
|
|
26
32
|
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
27
33
|
const post = (path, body) => pmPost(path, body).catch(() => ({ ok: false }));
|
|
@@ -104,64 +110,128 @@ export function needsSection(S) {
|
|
|
104
110
|
const count = el('span', { class: 'bdg g' }, '0');
|
|
105
111
|
const quick = el('span', { class: 'chips2 dim mono c3s2', 'data-needs-summary': '' });
|
|
106
112
|
const sumState = { dec: 0, own: 0, late: 0, quick: 0 };
|
|
107
|
-
|
|
113
|
+
/* the pools: each source builds its rows here; paint() ranks them together and places them */
|
|
114
|
+
const pool = { own: [], agent: [], dec: [] };
|
|
108
115
|
const ownNote = el('div');
|
|
109
|
-
const
|
|
110
|
-
const
|
|
116
|
+
const toWork = () => { if (hooks.go) hooks.go({ action: 'gowork' }); };
|
|
117
|
+
const heldBtn = button({ label: '', variant: 'ghost', size: 'sm', onClick: toWork, attrs: { hidden: '', 'data-needs-held': '', title: 'Open Work to see them' } });
|
|
118
|
+
const reviewBtn = button({ label: '', variant: 'ghost', size: 'sm', onClick: toWork, attrs: { hidden: '', 'data-needs-review': '', title: 'Open Work to review them' } });
|
|
119
|
+
const firstHead = el('div', { class: 'dim mono c3s2', hidden: '', 'data-needs-firsthead': '' });
|
|
120
|
+
const first = el('ul', { class: 'list', role: 'list', 'data-needs-first': '' });
|
|
121
|
+
const nextHead = el('div', { class: 'dim mono c3s2', hidden: '', 'data-needs-nexthead': '' }, 'Next up');
|
|
122
|
+
const list = el('ul', { class: 'list', role: 'list', 'data-needs-next': '' });
|
|
111
123
|
const extra = el('div');
|
|
112
124
|
const alertHost = el('div');
|
|
113
125
|
const box = el('section', { class: 'card sect', 'data-c3pg': 'needs', 'aria-label': 'Needs you' },
|
|
114
|
-
el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote,
|
|
115
|
-
let sig = ''; let last = null; let ownSig = ''; let page = null;
|
|
126
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote, heldBtn, reviewBtn, firstHead, first, nextHead, list, extra);
|
|
127
|
+
let sig = ''; let last = null; let ownSig = ''; let page = null; let held = null; let kept = null; let review = 0;
|
|
116
128
|
const nothing = () => el('li', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing needs you right now.');
|
|
129
|
+
const setLabel = (b, t) => { const l = b.querySelector(SEL.btnLabel); if (l) l.textContent = t; };
|
|
130
|
+
const rows = (host) => [...host.querySelectorAll('.nrow[data-needs-row]')];
|
|
117
131
|
/* the header line: "3 decisions · 4 of yours · 2 overdue", then how many take under 2 minutes */
|
|
118
132
|
const summarise = () => {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
133
|
+
const all = rows(box);
|
|
134
|
+
const dec = all.filter((r) => r.dataset.needsSrc === 'decision').length;
|
|
135
|
+
const ownRows = all.filter((r) => r.dataset.needsSrc === 'own'); const late = ownRows.filter((r) => r.dataset.kind === 'overdue').length;
|
|
121
136
|
if (!quick.isConnected) box.querySelector('.sh2').append(quick);
|
|
122
137
|
quick.replaceChildren(document.createTextNode(dec + (dec === 1 ? ' decision' : ' decisions') + ' · ' + ownRows.length + ' of yours'));
|
|
123
138
|
if (late) quick.append(' · ', el('span', { class: 'tag bad' }, late + ' overdue'));
|
|
124
|
-
const ag = agent.
|
|
139
|
+
const ag = all.filter((r) => r.dataset.needsSrc === 'agent').length;
|
|
125
140
|
if (ag) quick.append(' · ' + ag + ' from the agent');
|
|
126
141
|
if (sumState.quick) quick.append(' · ', c3ic('bolt', 'sm'), sumState.quick + ' under 2 min');
|
|
127
142
|
};
|
|
128
143
|
const recount = () => {
|
|
129
|
-
const n = box
|
|
144
|
+
const n = rows(box).length;
|
|
130
145
|
summarise();
|
|
131
146
|
count.textContent = String(n);
|
|
132
147
|
if (S.setNeedsBdg) S.setNeedsBdg(n);
|
|
133
|
-
const f = list.querySelector('.fold');
|
|
134
|
-
if (!n && !f) list.replaceChildren(nothing());
|
|
135
|
-
else if (n && f && list.children.length === 1 && f.textContent.startsWith('Nothing needs')) f.remove();
|
|
136
148
|
if (S.tagNeeds) S.tagNeeds(box); // Mentions (c3-brief-mentions.js): the "mentioned you" tag on a worth-asking row
|
|
137
149
|
if (S.briefRedraw) S.briefRedraw(); // The brief (c3-brief-summary.js) says what this section holds
|
|
138
150
|
};
|
|
151
|
+
|
|
152
|
+
/* the reasons a row ranks where it does: a line of chips under its title, each one a term of its own score */
|
|
153
|
+
const chipLine = (x, info) => {
|
|
154
|
+
const old = x.el.querySelector(':scope > .nb > [data-rank-chips]');
|
|
155
|
+
if (old) old.remove();
|
|
156
|
+
if (!info) return;
|
|
157
|
+
const host = x.el.querySelector(':scope > .nb') || x.el;
|
|
158
|
+
host.append(el('div', { class: 'chips rk', 'data-rank-chips': '' }, ...info.chips.map((c) => el('span', { class: 'chip2', 'data-term': c.term }, c.text))));
|
|
159
|
+
};
|
|
160
|
+
/* a group of three or more rows of one kind on one initiative: one row that opens to its members */
|
|
161
|
+
const groupRow = (r, info, byKey) => {
|
|
162
|
+
const members = (info.members || []).map((k) => byKey.get(k)).filter(Boolean);
|
|
163
|
+
const body = el('ul', { class: 'list', role: 'list' }, ...members.map((m) => m.el));
|
|
164
|
+
const li = el('li', { class: 'nrow', 'data-needs-group': r.key, 'data-needs-row': r.key, 'data-needs-src': 'group' },
|
|
165
|
+
el('div', { class: 'nb' }, el('details', null, el('summary', { class: 'tt' }, r.title), body)));
|
|
166
|
+
return li;
|
|
167
|
+
};
|
|
168
|
+
const place = (host, items) => host.replaceChildren(...items);
|
|
169
|
+
|
|
170
|
+
/* rank every row together and place them: Do these first, then Next up (five shown, the rest folded) */
|
|
171
|
+
const paint = () => {
|
|
172
|
+
for (const k of Object.keys(pool)) pool[k] = pool[k].filter((x) => !(x.el.__placed && !x.el.isConnected)); // a row the PM finished is gone
|
|
173
|
+
const all = [...pool.own, ...pool.agent, ...pool.dec];
|
|
174
|
+
const byKey = new Map(all.map((x) => [x.rank.key, x]));
|
|
175
|
+
const R = rank(all.map((x) => x.rank), { held });
|
|
176
|
+
const draw = (r) => {
|
|
177
|
+
const info = R.byKey.get(r.key);
|
|
178
|
+
if (info && info.members) { const g = groupRow(r, info, byKey); g.__placed = true; return g; }
|
|
179
|
+
const x = byKey.get(r.key);
|
|
180
|
+
if (!x) return null;
|
|
181
|
+
x.el.__placed = true; x.el.dataset.needsSrc = x.src; chipLine(x, info);
|
|
182
|
+
return x.el;
|
|
183
|
+
};
|
|
184
|
+
const firstEls = R.first.map(draw).filter(Boolean);
|
|
185
|
+
let rest = R.rest.slice();
|
|
186
|
+
if (S.pinNeed) { const at = rest.findIndex((r) => r.key === S.pinNeed); if (at > 0) rest.unshift(...rest.splice(at, 1)); }
|
|
187
|
+
const shown = rest.slice(0, 5).map(draw).filter(Boolean);
|
|
188
|
+
const below = rest.slice(5).map(draw).filter(Boolean);
|
|
189
|
+
firstEls.forEach((e, i) => { e.dataset.rank = String(i + 1); });
|
|
190
|
+
shown.concat(below).forEach((e) => { delete e.dataset.rank; });
|
|
191
|
+
place(first, firstEls);
|
|
192
|
+
firstHead.hidden = !firstEls.length;
|
|
193
|
+
const hard = R.hard.length;
|
|
194
|
+
firstHead.textContent = 'Do these first' + (hard > FIRST_N ? ' · ' + hard + ' cannot wait' : '');
|
|
195
|
+
nextHead.hidden = !(shown.length || below.length);
|
|
196
|
+
place(list, shown);
|
|
197
|
+
extra.replaceChildren(...belowLine(below, (e) => e, kept));
|
|
198
|
+
const hl = heldLine(R.held);
|
|
199
|
+
heldBtn.hidden = !hl; setLabel(heldBtn, hl ? 'Not on this list: ' + hl : '');
|
|
200
|
+
reviewBtn.hidden = !(review > 0); setLabel(reviewBtn, review > 0 ? review + (review === 1 ? ' item' : ' items') + ' untouched for a week. Review them' : '');
|
|
201
|
+
if (!firstEls.length && !shown.length && !below.length) first.replaceChildren(nothing());
|
|
202
|
+
recount();
|
|
203
|
+
};
|
|
204
|
+
|
|
139
205
|
/* What The brief reads of this section: the rows on the page now, with what each row's data says. null until both reads came back. */
|
|
140
206
|
const meta = new Map();
|
|
141
207
|
S.needsSnapshot = () => {
|
|
142
208
|
if (!last || !page || page.ok === false) return null;
|
|
143
|
-
const
|
|
144
|
-
const decisions =
|
|
209
|
+
const ordered = rows(box);
|
|
210
|
+
const decisions = ordered.filter((r) => r.dataset.needsSrc === 'decision').map((r) => {
|
|
145
211
|
const k = r.dataset.needsRow; const m = meta.get(k) || {}; const tt = r.querySelector('.tt');
|
|
146
212
|
return { key: k, headline: tt ? tt.textContent : '', time: m.time || null, decide: !!m.decide, overdue: !!m.overdue, days_late: m.days_late || 0 };
|
|
147
213
|
});
|
|
148
|
-
const own0 =
|
|
214
|
+
const own0 = ordered.filter((r) => r.dataset.needsSrc === 'own').map((r) => {
|
|
149
215
|
const k = r.dataset.needsRow; const d = (page.needs || []).find((x) => x.key === k) || {}; const tt = r.querySelector('.tt');
|
|
150
216
|
return { key: k, headline: tt ? tt.textContent : (d.headline || ''), kind: r.dataset.kind || d.kind, days_late: d.days_late || 0 };
|
|
151
217
|
});
|
|
152
|
-
return { decisions, own: own0, agentN: agent.
|
|
218
|
+
return { decisions, own: own0, agentN: ordered.filter((r) => r.dataset.needsSrc === 'agent').length };
|
|
153
219
|
};
|
|
154
|
-
const ctx = { changed:
|
|
220
|
+
const ctx = { changed: paint };
|
|
155
221
|
|
|
156
222
|
const fillOwn = (j) => {
|
|
157
223
|
page = j;
|
|
158
224
|
if (!j || j.ok === false) { ownNote.replaceChildren(el('p', { class: 'dim mono c3s2', role: 'status' }, 'Your own items could not be read: ' + ((j && j.error) || 'unreadable'))); return; }
|
|
159
225
|
ownNote.replaceChildren();
|
|
160
|
-
|
|
161
|
-
|
|
226
|
+
held = j.held && typeof j.held === 'object' ? j.held : null;
|
|
227
|
+
review = Number(j.review) > 0 ? Number(j.review) : 0;
|
|
228
|
+
const own = (j.needs || []).filter((r) => r.kind !== 'decision');
|
|
229
|
+
const g = JSON.stringify([own.map((r) => [r.key, r.headline, r.days_late, !!r.evidence, !!r.flagged, r.days_to_due]), held, review]);
|
|
162
230
|
if (g === ownSig) return; ownSig = g;
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
const have = new Map(pool.own.map((x) => [x.rank.key, x]));
|
|
232
|
+
const fresh = own.map((r) => { const old = have.get(r.key); return old && old.sig === JSON.stringify(r) ? old : { el: itemRow(r, { ...ctx, reload: () => import('./c3-brief-data.js').then((m) => m.loadBriefPage()) }), rank: fromOwn(r), src: 'own', sig: JSON.stringify(r) }; });
|
|
233
|
+
pool.own = fresh;
|
|
234
|
+
paint();
|
|
165
235
|
};
|
|
166
236
|
|
|
167
237
|
const fill = (b) => {
|
|
@@ -170,31 +240,30 @@ export function needsSection(S) {
|
|
|
170
240
|
let needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.map(fromCard).filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
|
|
171
241
|
const decide = ((b && b.cards) || []).filter(isDecision);
|
|
172
242
|
const key = needs ? needs.map((n) => n.key + n.headline) : decide.map((c) => (c.key || '') + c.headline);
|
|
173
|
-
if (needs && S.pinNeed) { const at = needs.findIndex((n) => n.key === S.pinNeed); if (at > 0) needs.unshift(...needs.splice(at, 1)); }
|
|
174
243
|
const g = JSON.stringify([key, inc, !!(b && b.kept_out), S.pinNeed || '']);
|
|
175
244
|
if (g === sig) return; sig = g;
|
|
245
|
+
kept = !inc && b && b.kept_out ? b.kept_out : null;
|
|
176
246
|
alertHost.replaceChildren(...(inc ? [el('div', { class: 'incident', role: 'status', 'data-c3pg': 'incident' }, c3ic('alert', 'lg'), el('div', null, el('b', null, 'Card payments failing since 07:40'), el('div', { class: 'dim c3s3' }, '12% of checkouts. Sam on call. Only deadlines shown.')))] : []));
|
|
177
247
|
meta.clear();
|
|
178
248
|
/* a decision only the PM can make: an engine card, or an assigned item marked Decide; an assigned item past its date is overdue */
|
|
179
|
-
|
|
249
|
+
const rowsIn = needs || decide.map((c, i) => fromCard({ key: c.key || String(i), card: c }, i));
|
|
250
|
+
rowsIn.forEach((n) => {
|
|
180
251
|
const late = /^(\d+) days? overdue$/.exec(String(n.dueText || ''));
|
|
181
252
|
meta.set(n.key, { time: n.time || null, decide: !!n.view || /^decide$/i.test(String(n.kind || '')), overdue: n.kind === 'Overdue', days_late: late ? Number(late[1]) : 0 });
|
|
182
253
|
});
|
|
254
|
+
const have = new Map(pool.dec.map((x) => [x.rank.key, x]));
|
|
255
|
+
const make = (n, i) => { const old = have.get(String(n.key)); return old && old.sig === JSON.stringify([n.key, n.headline]) ? old : { el: needRow(n, i, ctx), rank: fromDecision(n), src: 'decision', sig: JSON.stringify([n.key, n.headline]) }; };
|
|
183
256
|
if (needs) {
|
|
184
257
|
sumState.quick = needs.filter((n) => n.quick).length;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
258
|
+
pool.dec = needs.map(make);
|
|
259
|
+
} else {
|
|
260
|
+
sumState.quick = 0;
|
|
261
|
+
pool.dec = decide.map(cardView).map((v, i) => {
|
|
262
|
+
const T = TAG_STYLE[v.tag] || TAG_STYLE['Just in'];
|
|
263
|
+
return make({ key: v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v }, i);
|
|
264
|
+
});
|
|
189
265
|
}
|
|
190
|
-
|
|
191
|
-
sumState.quick = 0;
|
|
192
|
-
list.replaceChildren(...cards.map((v, i) => {
|
|
193
|
-
const T = TAG_STYLE[v.tag] || TAG_STYLE['Just in'];
|
|
194
|
-
return needRow({ key: v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v }, i, ctx);
|
|
195
|
-
}));
|
|
196
|
-
extra.replaceChildren();
|
|
197
|
-
recount();
|
|
266
|
+
paint();
|
|
198
267
|
};
|
|
199
268
|
/* the agent's rows: drafts waiting on the PM, a target the PM is blocking. A press opens the initiative (its Overview holds the strip) or Work. */
|
|
200
269
|
let agentSig = '';
|
|
@@ -204,11 +273,11 @@ export function needsSection(S) {
|
|
|
204
273
|
if (n.initiative_id) { try { const u = new URL(location.href); u.searchParams.set('init', 'ini:' + encodeURIComponent(n.ws) + ':' + encodeURIComponent(n.initiative_id) + ':overview'); history.replaceState(history.state, '', u); } catch { /* no history */ } }
|
|
205
274
|
if (hooks.go) hooks.go({ action: n.initiative_id ? 'goinit' : 'gowork' });
|
|
206
275
|
};
|
|
207
|
-
const fillAgent = () => loadDriverNeeds().then((
|
|
208
|
-
const g = JSON.stringify(
|
|
276
|
+
const fillAgent = () => loadDriverNeeds().then((rs) => {
|
|
277
|
+
const g = JSON.stringify(rs.map((r) => [r.run_id, r.question, r.rank || null]));
|
|
209
278
|
if (g === agentSig) return; agentSig = g;
|
|
210
|
-
agent.
|
|
211
|
-
|
|
279
|
+
pool.agent = rs.map((r) => ({ el: driverRow(r, goAgent), rank: fromAgent(r), src: 'agent' }));
|
|
280
|
+
paint();
|
|
212
281
|
}, () => {});
|
|
213
282
|
S.refreshNeeds = () => { sig = ''; if (last) fill(last); };
|
|
214
283
|
S.needsFill = fill;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/* pm/priority-model.js — the one ranking for every list that asks for the PM's attention (initiative briefing-prioritization, PRD planning/prd/briefing-prioritization-prd.md).
|
|
2
|
+
* PURE. No DOM, no fetch, no clock (the caller passes what it needs), no model call, no write. Same rows in, same order out.
|
|
3
|
+
*
|
|
4
|
+
* rank(rows, { held }) → { first, rest, hard, hidden, held, byKey }
|
|
5
|
+
* rows RankRows (below). `first` is the hard lane plus the best rows up to three; `rest` is the rest in score order;
|
|
6
|
+
* `hidden` counts what stage 1 left out by reason; `held` is the counts the server supplied (waiting, later, someday).
|
|
7
|
+
* rankCards(cards) → cards the headline deck, in the same order the Needs you rows would take
|
|
8
|
+
* rankMentions(list) → list reply-needed first, then the one who has waited longest
|
|
9
|
+
* fromOwn(r) · fromDecision(n) · fromAgent(r) · fromCard(c) the adapters from the page's row shapes to a RankRow
|
|
10
|
+
* effortOf(row) · scoreOf(row) · chipsOf(row, scored) · heldLine(counts)
|
|
11
|
+
*
|
|
12
|
+
* Stage 1 (availability): a row the PM cannot act on now is left out and counted. A Critical or flagged row is never left out for waiting,
|
|
13
|
+
* deferral or Someday; only done or read removes it.
|
|
14
|
+
* Stage 2: the hard lane (Critical; a promise to someone outside that is due or overdue) sits above every score. The rest take
|
|
15
|
+
* priority = (0.45 impact + 0.35 urgency + 0.20 irreversibility) × (1 + blocking) × aging × flag boost ÷ effort
|
|
16
|
+
* The base is the signal engine's own (scripts/signals/score.js). The constants below are starting values; the replay
|
|
17
|
+
* (scripts/briefing-replay.js) compares them with the PM's own order before they are trusted.
|
|
18
|
+
*
|
|
19
|
+
* A RankRow: { key, title, source, kind, critical, promise, person, days_late (0 is due today, null when no date), days_to_due,
|
|
20
|
+
* priority 'P1'..'P3', impact 0..1, urgency 0..1, irreversible, blocked_runs, people_waiting, age_days, flagged, off_track,
|
|
21
|
+
* effort 'S'|'M'|'L', effort_hint, group, hidden }. A field the record does not hold is null, never guessed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const W = Object.freeze({ impact: 0.45, urgency: 0.35, irreversible: 0.20 });
|
|
25
|
+
export const EFFORT_DIV = Object.freeze({ S: 1.0, M: 1.4, L: 2.0 });
|
|
26
|
+
export const EFFORT_WORD = Object.freeze({ S: 'about 2 min', M: 'about 15 min', L: 'an hour or more' });
|
|
27
|
+
export const PRIORITY_IMPACT = Object.freeze({ P1: 1.0, P2: 0.7, P3: 0.5 });
|
|
28
|
+
export const FIRST_N = 3;
|
|
29
|
+
const BLOCK_STEP = 0.25; const BLOCK_CAP = 0.75; const AGING_DAYS = 7; const AGING_MAX = 0.5; const FLAG_BOOST = 1.5; const GROUP_MIN = 3;
|
|
30
|
+
/* the kinds of row whose effort the table fixes (PRD, "Effort is inferred from the kind of row") */
|
|
31
|
+
export const HINT_EFFORT = Object.freeze({ drafts: 'S', ask: 'S', quick: 'S', decision: 'S', reply: 'S', offline: 'M', 'card-move': 'M', 'blocked-or-overdue': 'M', plain: 'M' });
|
|
32
|
+
const LONG_WORK = /\b(review|spec|prd|write-?up|proposal|strategy|roadmap)\b/i;
|
|
33
|
+
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
|
|
34
|
+
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
|
35
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
|
|
36
|
+
|
|
37
|
+
/** The engine's urgency step over the days left; overdue and due today are 1. */
|
|
38
|
+
export function urgencyOf(row) {
|
|
39
|
+
if (num(row.urgency) !== null) return clamp01(row.urgency);
|
|
40
|
+
const late = num(row.days_late);
|
|
41
|
+
if (late !== null) return 1;
|
|
42
|
+
const d = num(row.days_to_due);
|
|
43
|
+
if (d === null) return 0.1;
|
|
44
|
+
return d <= 1 ? 1 : d <= 3 ? 0.8 : d <= 7 ? 0.5 : d <= 30 ? 0.3 : 0.1;
|
|
45
|
+
}
|
|
46
|
+
export function impactOf(row) {
|
|
47
|
+
let v = num(row.impact);
|
|
48
|
+
if (v === null) v = PRIORITY_IMPACT[row.priority] !== undefined ? PRIORITY_IMPACT[row.priority] : 0.5;
|
|
49
|
+
return clamp01(v + (row.off_track ? 0.2 : 0));
|
|
50
|
+
}
|
|
51
|
+
/** The effort bucket and where it came from: 'given' (the record held it), 'kind' (the table), 'title' (the L words) or 'unknown'. */
|
|
52
|
+
export function effortOf(row) {
|
|
53
|
+
if (EFFORT_DIV[row.effort]) return { bucket: row.effort, from: 'given' };
|
|
54
|
+
const h = HINT_EFFORT[row.effort_hint];
|
|
55
|
+
if (h === 'S') return { bucket: 'S', from: 'kind' };
|
|
56
|
+
if (LONG_WORK.test(String(row.title || ''))) return { bucket: 'L', from: 'title' };
|
|
57
|
+
if (h) return { bucket: h, from: 'kind' };
|
|
58
|
+
return { bucket: 'M', from: 'unknown' };
|
|
59
|
+
}
|
|
60
|
+
export const blockingOf = (row) => Math.min(BLOCK_CAP, BLOCK_STEP * ((num(row.blocked_runs) || 0) + (num(row.people_waiting) || 0)));
|
|
61
|
+
export const agingOf = (row) => 1 + AGING_MAX * Math.min(1, Math.max(0, num(row.age_days) || 0) / AGING_DAYS);
|
|
62
|
+
/** A promise to someone outside, due today or overdue. The builder sets `promise`; the date test is here. */
|
|
63
|
+
export const promiseDue = (row) => !!row.promise && num(row.days_late) !== null;
|
|
64
|
+
export const isHard = (row) => !!row.critical || promiseDue(row);
|
|
65
|
+
|
|
66
|
+
/** The terms of one row's score and the score. The chips and the card's "why" read these, so they cannot disagree with the order. */
|
|
67
|
+
export function scoreOf(row) {
|
|
68
|
+
const impact = impactOf(row); const urgency = urgencyOf(row); const irreversible = row.irreversible === true ? 1 : 0.5;
|
|
69
|
+
const blocking = blockingOf(row); const aging = agingOf(row); const flag = row.flagged ? FLAG_BOOST : 1;
|
|
70
|
+
const eff = effortOf(row); const div = EFFORT_DIV[eff.bucket];
|
|
71
|
+
const base = W.impact * impact + W.urgency * urgency + W.irreversible * irreversible;
|
|
72
|
+
const score = +(base * (1 + blocking) * aging * flag / div).toFixed(4);
|
|
73
|
+
return { score, base: +base.toFixed(4), impact, urgency, irreversible, blocking, aging, flag, effort: eff.bucket, effort_from: eff.from, divisor: div };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Why a row is where it is, in plain words, each chip tied to a term of its own score. At most four. */
|
|
77
|
+
export function chipsOf(row, s = scoreOf(row)) {
|
|
78
|
+
const out = [];
|
|
79
|
+
if (row.critical) out.push({ term: 'critical', text: 'Critical' });
|
|
80
|
+
if (promiseDue(row)) out.push({ term: 'promise', text: 'Promise' + (row.person ? ' to ' + row.person : '') + (row.days_late > 0 ? ', ' + plural(row.days_late, 'day', 'days') + ' late' : ', due today') });
|
|
81
|
+
else if (num(row.days_late) !== null) out.push({ term: 'urgency', text: row.days_late > 0 ? plural(row.days_late, 'day', 'days') + ' late' : 'Due today' });
|
|
82
|
+
else if (num(row.days_to_due) !== null && row.days_to_due <= 7) out.push({ term: 'urgency', text: row.days_to_due <= 0 ? 'Due today' : 'Due in ' + plural(row.days_to_due, 'day', 'days') });
|
|
83
|
+
const runs = num(row.blocked_runs) || 0; const ppl = num(row.people_waiting) || 0;
|
|
84
|
+
if (runs) out.push({ term: 'blocking', text: runs === 1 ? 'Frees 1 agent run' : 'Frees ' + runs + ' agent runs' });
|
|
85
|
+
if (ppl) out.push({ term: 'blocking', text: plural(ppl, 'person waits', 'people wait') });
|
|
86
|
+
if ((num(row.age_days) || 0) >= 2) out.push({ term: 'aging', text: plural(Math.floor(row.age_days), 'day', 'days') + ' old' });
|
|
87
|
+
if (row.irreversible === true) out.push({ term: 'irreversible', text: 'Hard to undo' });
|
|
88
|
+
if (row.flagged) out.push({ term: 'flag', text: 'Flagged' });
|
|
89
|
+
const lead = out.slice(0, 3);
|
|
90
|
+
lead.push({ term: 'effort', text: EFFORT_WORD[s.effort] });
|
|
91
|
+
return lead;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
95
|
+
/* ties: more days late, then older, then title, then key; so the order never depends on input order */
|
|
96
|
+
const tie = (a, b) => (num(b.row.days_late) || 0) - (num(a.row.days_late) || 0) || (num(b.row.age_days) || 0) - (num(a.row.age_days) || 0) || cmp(String(a.row.title || ''), String(b.row.title || '')) || cmp(String(a.row.key), String(b.row.key));
|
|
97
|
+
const byScore = (a, b) => b.s.score - a.s.score || tie(a, b);
|
|
98
|
+
|
|
99
|
+
const HIDE_WORD = { waiting: 'waiting', deferred: 'later', someday: 'someday' };
|
|
100
|
+
/** Stage 1. A row is hidden when it carries a `hidden` reason, except that Critical and flagged rows stay unless done or read. */
|
|
101
|
+
export function available(row) {
|
|
102
|
+
if (!row.hidden) return true;
|
|
103
|
+
if (row.hidden === 'done' || row.hidden === 'read') return false;
|
|
104
|
+
return isHard(row) || !!row.flagged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Collapse three or more rows that share a `group` into one row. The group takes the best score and the summed blocking. */
|
|
108
|
+
function collapse(items) {
|
|
109
|
+
const by = new Map();
|
|
110
|
+
for (const it of items) { const g = it.row.group; if (g) { if (!by.has(g)) by.set(g, []); by.get(g).push(it); } }
|
|
111
|
+
const out = []; const done = new Set();
|
|
112
|
+
for (const it of items) {
|
|
113
|
+
const g = it.row.group;
|
|
114
|
+
if (!g || by.get(g).length < GROUP_MIN) { out.push(it); continue; }
|
|
115
|
+
if (done.has(g)) continue;
|
|
116
|
+
done.add(g);
|
|
117
|
+
const m = by.get(g).slice().sort(byScore);
|
|
118
|
+
const head = m[0].row;
|
|
119
|
+
const runs = m.reduce((a, x) => a + (num(x.row.blocked_runs) || 0), 0); const ppl = m.reduce((a, x) => a + (num(x.row.people_waiting) || 0), 0);
|
|
120
|
+
const row = { ...head, key: 'group:' + g, title: m.length + ' ' + (head.kind ? String(head.kind).toLowerCase() + ' items' : 'items') + (head.group_label ? ' on ' + head.group_label : ''),
|
|
121
|
+
blocked_runs: runs, people_waiting: ppl, critical: m.some((x) => x.row.critical), promise: m.some((x) => promiseDue(x.row)), group_members: m.map((x) => x.row.key) };
|
|
122
|
+
out.push({ row, s: scoreOf(row), members: m });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function rank(rows, opts = {}) {
|
|
128
|
+
const list = (Array.isArray(rows) ? rows : []).filter((r) => r && r.key != null);
|
|
129
|
+
const hidden = { waiting: 0, later: 0, someday: 0, done: 0, read: 0 };
|
|
130
|
+
const live = [];
|
|
131
|
+
for (const row of list) {
|
|
132
|
+
if (available(row)) live.push({ row, s: scoreOf(row) });
|
|
133
|
+
else if (row.hidden === 'done' || row.hidden === 'read') hidden[row.hidden] += 1;
|
|
134
|
+
else hidden[HIDE_WORD[row.hidden] || 'later'] += 1;
|
|
135
|
+
}
|
|
136
|
+
const items = collapse(live);
|
|
137
|
+
const hard = items.filter((x) => isHard(x.row)).sort(byScore);
|
|
138
|
+
const soft = items.filter((x) => !isHard(x.row)).sort(byScore);
|
|
139
|
+
const first = hard.length >= FIRST_N ? hard : [...hard, ...soft.slice(0, FIRST_N - hard.length)];
|
|
140
|
+
const rest = soft.slice(first.length - hard.length);
|
|
141
|
+
const held = { waiting: 0, later: 0, someday: 0, ...(opts.held || {}) };
|
|
142
|
+
const byKey = new Map();
|
|
143
|
+
for (const x of [...first, ...rest]) byKey.set(x.row.key, { score: x.s, chips: chipsOf(x.row, x.s), hard: isHard(x.row), members: x.members ? x.members.map((m) => m.row.key) : null });
|
|
144
|
+
return { first: first.map((x) => x.row), rest: rest.map((x) => x.row), hard: hard.map((x) => x.row), hidden, held, byKey };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** "4 waiting on others · 3 later · 2 someday", or '' when nothing is held. */
|
|
148
|
+
export function heldLine(c) {
|
|
149
|
+
const h = c || {};
|
|
150
|
+
const p = [];
|
|
151
|
+
if (h.waiting > 0) p.push(h.waiting + ' waiting on others');
|
|
152
|
+
if (h.later > 0) p.push(h.later + ' later');
|
|
153
|
+
if (h.someday > 0) p.push(h.someday + ' someday');
|
|
154
|
+
return p.join(' · ');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ── adapters: the page's row shapes → RankRow ─────────────────────────────────────────────────────────────── */
|
|
158
|
+
const lateOf = (s) => { const m = /^(\d+) days? overdue$/.exec(String(s || '')); return m ? Number(m[1]) : /^due today$/i.test(String(s || '')) ? 0 : null; };
|
|
159
|
+
|
|
160
|
+
/** An item of the PM's own Work list (GET /api/brief-page `needs`: kind overdue | due-today | offline). */
|
|
161
|
+
export function fromOwn(r) {
|
|
162
|
+
const offline = !!r.offline || r.kind === 'offline';
|
|
163
|
+
/* the server sends `days_to_due` (negative when late, 0 today, null with no date); an outside item with no date has no lateness */
|
|
164
|
+
const toDue = num(r.days_to_due);
|
|
165
|
+
let late = null; let ahead = null;
|
|
166
|
+
if (r.kind === 'overdue') late = Math.max(1, Number(r.days_late) || 1);
|
|
167
|
+
else if (r.kind === 'due-today') late = 0;
|
|
168
|
+
else if (toDue !== null) { if (toDue < 0) late = -toDue; else if (toDue === 0) late = 0; else ahead = toDue; }
|
|
169
|
+
else if (Number(r.days_late) > 0) late = Number(r.days_late);
|
|
170
|
+
return {
|
|
171
|
+
key: String(r.key), title: r.headline || '', source: 'own', kind: r.kind, person: r.person || null,
|
|
172
|
+
days_late: late, days_to_due: ahead, age_days: late, priority: r.priority || null, flagged: !!r.flagged, promise: offline && !!r.person,
|
|
173
|
+
effort_hint: r.quick || r.evidence ? 'quick' : offline ? 'offline' : 'plain', // a row that looks done only needs a confirm
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A decision row of Needs you (a Jira or plan item, a brief ask, or an engine card drawn by fromCard in c3-brief-needs.js). */
|
|
178
|
+
export function fromDecision(n) {
|
|
179
|
+
const v = n.view || null;
|
|
180
|
+
const late = lateOf(n.dueText);
|
|
181
|
+
const kind = String(n.kind || '');
|
|
182
|
+
const row = { key: String(n.key), title: n.headline || '', source: v ? 'engine' : n.origin || 'jira', kind, priority: /^P[1-3]$/.test(String(n.p || '')) ? n.p : null, days_late: late, age_days: late };
|
|
183
|
+
if (v) {
|
|
184
|
+
row.critical = v.tag === 'Critical';
|
|
185
|
+
const t = v.terms || {};
|
|
186
|
+
row.impact = num(t.impact); row.urgency = num(t.urgency); row.irreversible = t.irreversible === 1 ? true : t.irreversible === 0.5 ? false : null;
|
|
187
|
+
row.days_to_due = v.deadline && num(v.deadline.days) !== null && v.deadline.days >= 0 ? v.deadline.days : null;
|
|
188
|
+
row.days_late = v.deadline && num(v.deadline.days) !== null && v.deadline.days < 0 ? Math.ceil(-v.deadline.days) : null;
|
|
189
|
+
row.effort_hint = 'card-move';
|
|
190
|
+
return row;
|
|
191
|
+
}
|
|
192
|
+
row.effort_hint = /^decide$/i.test(kind) ? 'decision' : /^worth asking$/i.test(kind) ? 'ask' : /^(overdue|blocked)$/i.test(kind) ? 'blocked-or-overdue' : 'plain';
|
|
193
|
+
return row;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A row of the Initiative Driver (GET /api/needs: driver-drafts, driver-blocked, with the ledger's age and held runs). */
|
|
197
|
+
export function fromAgent(r) {
|
|
198
|
+
const k = r.rank && typeof r.rank === 'object' ? r.rank : {};
|
|
199
|
+
const f = k.facts || {};
|
|
200
|
+
const eff = EFFORT_DIV[f.effort] ? f.effort : null;
|
|
201
|
+
return {
|
|
202
|
+
key: String(r.run_id), title: r.question || '', source: 'agent', kind: r.kind === 'driver-blocked' ? 'blocked' : 'drafts',
|
|
203
|
+
blocked_runs: num(k.blocked_runs) || 0, age_days: num(k.age_days), irreversible: f.reversible === false ? true : f.reversible === true ? false : null,
|
|
204
|
+
effort: eff, effort_hint: 'drafts', group: r.initiative_id ? 'agent:' + r.kind + ':' + r.initiative_id : null,
|
|
205
|
+
off_track: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A headline-deck card (engine or real) as a RankRow. */
|
|
210
|
+
export function fromCard(c) {
|
|
211
|
+
const t = (c.why && typeof c.why === 'object') ? c.why : {};
|
|
212
|
+
const d = c.deadline && typeof c.deadline === 'object' ? c.deadline : null;
|
|
213
|
+
const days = d && num(d.days) !== null ? d.days : null;
|
|
214
|
+
return {
|
|
215
|
+
key: String(c.key || c.headline || ''), title: c.headline || '', source: c.origin || 'engine', kind: c.kind || c.tag || '',
|
|
216
|
+
critical: c.tag === 'Critical' && !c.origin, days_late: days !== null && days <= 0 && c.origin ? Math.ceil(-days) : null,
|
|
217
|
+
days_to_due: days !== null && days > 0 ? days : null, impact: num(t.impact), urgency: num(t.urgency), irreversible: t.irreversible === 1 ? true : t.irreversible === 0.5 ? false : null,
|
|
218
|
+
effort_hint: c.move ? 'card-move' : 'plain', pinned: !!c.pinned,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The deck in rank order: pinned Critical first, the hard lane, then by score. Cards the model cannot read keep their place at the end. */
|
|
223
|
+
export function rankCards(cards) {
|
|
224
|
+
const list = Array.isArray(cards) ? cards : [];
|
|
225
|
+
const items = list.map((c, i) => { const row = fromCard(c); return { c, i, row, s: scoreOf(row) }; });
|
|
226
|
+
const grade = (x) => (x.row.pinned ? 0 : isHard(x.row) ? 1 : 2);
|
|
227
|
+
return items.sort((a, b) => grade(a) - grade(b) || b.s.score - a.s.score || a.i - b.i).map((x) => x.c);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Mentions: a reply that is needed comes before information; then the one who has waited longest; unread before read. */
|
|
231
|
+
export function rankMentions(list) {
|
|
232
|
+
const items = (Array.isArray(list) ? list : []).map((m, i) => ({ m, i }));
|
|
233
|
+
const at = (m) => (m.at ? Date.parse(m.at) : NaN);
|
|
234
|
+
return items.sort((a, b) => (b.m.reply === true) - (a.m.reply === true) || (a.m.read === true) - (b.m.read === true)
|
|
235
|
+
|| ((Number.isFinite(at(a.m)) ? at(a.m) : Infinity) - (Number.isFinite(at(b.m)) ? at(b.m) : Infinity)) || a.i - b.i).map((x) => x.m);
|
|
236
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* The rule, in order: parked is Someday; waiting on someone is Waiting; a "not before" (defer) date after today is Upcoming;
|
|
8
8
|
* flagged is Today; due today or earlier is Today; a later due date is Upcoming; no date is Inbox. Dates are YYYY-MM-DD strings.
|
|
9
9
|
* Pure: no DOM, no fetch. */
|
|
10
|
+
import { rank } from './priority-model.js';
|
|
10
11
|
|
|
11
12
|
export const VIEWS = Object.freeze(['inbox', 'today', 'upcoming', 'waiting', 'someday']);
|
|
12
13
|
export const REVIEW_DAYS = 7;
|
|
@@ -38,8 +39,17 @@ const byStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
|
38
39
|
export function sortView(view, list, today) {
|
|
39
40
|
const l = list.slice();
|
|
40
41
|
if (view === 'today') {
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
/* the one ranking (priority-model.js), the same as Needs you: an item the PM can act on today, by its lateness, priority, flag and kind.
|
|
43
|
+
* Ties keep the old order: due date, then time. */
|
|
44
|
+
const old = l.slice().sort((a, b) => byStr(day(a.due) || '9999', day(b.due) || '9999') || byStr(String(a.time || ''), String(b.time || '')));
|
|
45
|
+
const rows = old.map((i, n) => {
|
|
46
|
+
const late = i.due && day(i.due) <= today ? daysBetween(day(i.due), today) : null;
|
|
47
|
+
const offline = i.kind === 'offline';
|
|
48
|
+
return { key: String(n), title: i.title || '', source: 'work', kind: i.kind || '', person: i.person || null, days_late: late, age_days: late, priority: /^P[1-3]$/.test(String(i.priority || '')) ? i.priority : null,
|
|
49
|
+
flagged: !!i.flagged, promise: offline && !!i.person, effort_hint: offline ? 'offline' : 'plain' };
|
|
50
|
+
});
|
|
51
|
+
const r = rank(rows);
|
|
52
|
+
return [...r.first, ...r.rest].map((x) => old[Number(x.key)]);
|
|
43
53
|
} else if (view === 'upcoming') l.sort((a, b) => byStr(arrives(a, today), arrives(b, today)));
|
|
44
54
|
else if (view === 'inbox') l.sort((a, b) => byStr(String(b.created_at || ''), String(a.created_at || '')));
|
|
45
55
|
return l;
|
package/workspace/server.js
CHANGED
|
@@ -5153,6 +5153,8 @@ function needsResult(w) {
|
|
|
5153
5153
|
...(n.ws ? { ws: n.ws } : {}), ...(n.batch ? { batch: n.batch } : {}), ...(n.item ? { item: n.item } : {}), ...(n.flag ? { flag: n.flag } : {}),
|
|
5154
5154
|
// pm-os-absorb Runs 2–5 — a provider row's Today card (lib/needs-providers.js); absent on every other row.
|
|
5155
5155
|
...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
|
|
5156
|
+
// briefing-prioritization — what the Briefing's ranking reads of an agent row (age, held runs, the agent's own facts); absent on every other row.
|
|
5157
|
+
...(n.rank && typeof n.rank === 'object' ? { rank: n.rank } : {}),
|
|
5156
5158
|
}));
|
|
5157
5159
|
// `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
|
|
5158
5160
|
// both the strict state and the queue from one derivation instead of deriving twice per request.
|
|
@@ -5871,13 +5873,30 @@ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; ch
|
|
|
5871
5873
|
const zlib = require('zlib');
|
|
5872
5874
|
const GZIP_MIN_BYTES = 1024;
|
|
5873
5875
|
const GZIP_TYPES = /^(?:text\/|application\/(?:json|javascript|manifest\+json)|image\/svg\+xml)/;
|
|
5874
|
-
|
|
5876
|
+
// Whether this client takes a gzip body: a `gzip` (or `*`) coding in Accept-Encoding whose q-value is not 0. `gzip;q=0` is a
|
|
5877
|
+
// refusal. PMOS_NO_COMPRESS=1 turns compression off, so a proxy or client that mishandles it can be ruled out in one restart.
|
|
5878
|
+
const NO_COMPRESS = process.env.PMOS_NO_COMPRESS === '1';
|
|
5879
|
+
function wantsGzip(res) {
|
|
5880
|
+
if (NO_COMPRESS) return false;
|
|
5881
|
+
const q = res && res.req;
|
|
5882
|
+
const h = q && q.headers && q.headers['accept-encoding'];
|
|
5883
|
+
if (!h) return false;
|
|
5884
|
+
for (const part of String(h).split(',')) {
|
|
5885
|
+
const [coding, ...params] = part.trim().toLowerCase().split(';');
|
|
5886
|
+
if (coding !== 'gzip') continue;
|
|
5887
|
+
const qv = params.map((x) => x.trim()).find((x) => x.startsWith('q='));
|
|
5888
|
+
return !(qv && Number(qv.slice(2)) === 0);
|
|
5889
|
+
}
|
|
5890
|
+
return false;
|
|
5891
|
+
}
|
|
5875
5892
|
const fileMemo = new Map(); // file -> { sig, buf, etag, gz }
|
|
5876
5893
|
const FILE_MEMO_MAX = 400;
|
|
5877
5894
|
function serveFile(res, file, type, noCache) {
|
|
5878
5895
|
fs.stat(file, (serr, st) => {
|
|
5879
5896
|
if (serr || !st.isFile()) { res.writeHead(404); res.end('not found'); return; }
|
|
5880
|
-
|
|
5897
|
+
// size, mtime, change time and inode: a package install stamps every file with one fixed mtime, so size and mtime alone
|
|
5898
|
+
// would keep serving the old bytes after a same-size edit.
|
|
5899
|
+
const sig = `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
|
|
5881
5900
|
const send = (m) => {
|
|
5882
5901
|
const ctype = type || TYPES[path.extname(file)] || 'application/octet-stream';
|
|
5883
5902
|
const headers = { 'Content-Type': ctype, 'ETag': m.etag };
|
|
@@ -5900,7 +5919,7 @@ function serveFile(res, file, type, noCache) {
|
|
|
5900
5919
|
if (hit && hit.sig === sig) return send(hit);
|
|
5901
5920
|
fs.readFile(file, (err, buf) => {
|
|
5902
5921
|
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
5903
|
-
const m = { sig, buf, etag: `
|
|
5922
|
+
const m = { sig, buf, etag: `"${require('crypto').createHash('sha1').update(buf).digest('hex').slice(0, 20)}"`, gz: null };
|
|
5904
5923
|
const ctype = type || TYPES[path.extname(file)] || '';
|
|
5905
5924
|
const finish = () => { if (fileMemo.size >= FILE_MEMO_MAX) fileMemo.clear(); fileMemo.set(file, m); send(m); };
|
|
5906
5925
|
if (buf.length >= GZIP_MIN_BYTES && GZIP_TYPES.test(ctype)) zlib.gzip(buf, (e, gz) => { if (!e) m.gz = gz; finish(); });
|
|
@@ -6019,6 +6038,7 @@ function json(res, obj, code = 200) {
|
|
|
6019
6038
|
if (body.length >= GZIP_MIN_BYTES && wantsGzip(res)) {
|
|
6020
6039
|
// Async, so a 1 MB library answer costs the loop nothing beyond the stringify (workspace-event-loop-lag).
|
|
6021
6040
|
return zlib.gzip(body, (e, gz) => {
|
|
6041
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return; // the handler or the client got there first
|
|
6022
6042
|
if (e) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(body); return; }
|
|
6023
6043
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding', 'Content-Length': gz.length });
|
|
6024
6044
|
res.end(gz);
|
|
@@ -42,6 +42,7 @@ Procedure:
|
|
|
42
42
|
6. File each proposal like this, with one `--source` for each pointer:
|
|
43
43
|
`pmos driver propose initiative <id> --what evidence --file /tmp/evidence.json --why "<one line: why the PM needs this>" --source "Jira DISC-12=https://…" --source "Meeting note=notes/2026-10-08.md"`
|
|
44
44
|
Write the JSON or text file in the scratch folder of this session. If `pmos driver propose` refuses, read the reason, fix the proposal and file it once more. Do not work around a refusal.
|
|
45
|
+
State what you know, so that the Briefing can rank this for the PM: `--confidence <0 to 1>` (how sure you are), `--recommended "<what you suggest the PM does>"`, `--reversible true|false` (whether the PM can undo it) and `--effort S|M|L` (the PM's time: about 2 minutes, about 15 minutes, an hour or more). Add a flag only when you know its value. Never guess one.
|
|
45
46
|
7. Finish with the output contract. Do not repeat the proposals in full.
|
|
46
47
|
8. Core documents of this stage (due or stale, named in the prompt). For each one: read its sources (the record, the files it names, the Jira snapshot, the notes that name the initiative), write the document once with
|
|
47
48
|
`pmos coredoc write --scope initiative:<id> --key <key> --file - <<'PMOS_DOC'` … `PMOS_DOC` (the document on standard input; every fact with its source in brackets; "not known" where no source says), then file it:
|
|
@@ -33,6 +33,7 @@ Procedure:
|
|
|
33
33
|
3. File each proposal like this:
|
|
34
34
|
`pmos driver propose work <id> --what follow-up --file /tmp/followup.json --why "<one line: why the PM needs this>" --source "Slack thread=https://…"`
|
|
35
35
|
Write the JSON file in the scratch folder of this session. If `pmos driver propose` refuses, read the reason, fix the proposal and file it once more. Do not work around a refusal.
|
|
36
|
+
State what you know, so that the Briefing can rank this for the PM: `--confidence <0 to 1>` (how sure you are), `--recommended "<what you suggest the PM does>"`, `--reversible true|false` (whether the PM can undo it) and `--effort S|M|L` (the PM's time: about 2 minutes, about 15 minutes, an hour or more). Add a flag only when you know its value. Never guess one.
|
|
36
37
|
4. If you found nothing to act on, file nothing. A run with nothing new is a correct run.
|
|
37
38
|
5. Finish with the output contract.
|
|
38
39
|
|