create-pmos 0.45.0 → 0.47.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/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +24 -2
- package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
- package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
- package/payload/mods/pmos-cockpit/hooks/lib/mode.mjs +45 -0
- package/payload/mods/pmos-cockpit/hooks/lib/review.mjs +117 -0
- package/payload/mods/pmos-cockpit/hooks/lib/unpushed.mjs +53 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +283 -3
- package/payload/mods/pmos-cockpit/types/index.d.ts +17 -1
- package/payload/okf/core/concepts/frontmatter-format.md +6 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/skills/SKILL-FORMAT.md +1 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +1 -1
- package/workspace/guide/initiatives-and-next-steps.md +1 -0
- package/workspace/guide/knowledge-graph.md +6 -1
- package/workspace/guide/layout.md +1 -0
- package/workspace/guide/proactive-engine.md +21 -3
- package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
- package/workspace/guide/statuses.md +1 -0
- package/workspace/guide/validate-with-typesafe.md +1 -0
- package/workspace/lib/kg-cli.js +2 -1
- package/workspace/lib/kg-dangling-baseline.json +1 -0
- package/workspace/lib/kg-dangling-rules.json +1 -0
- package/workspace/lib/kg.js +20 -8
- package/workspace/lib/learn.js +280 -0
- package/workspace/lib/proactive-cli.js +49 -1
- package/workspace/lib/proactive.js +8 -7
- package/workspace/lib/session-flag.js +35 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/modes-live.js +52 -4
- package/workspace/server.js +108 -5
- package/workspace/skills/morning-brief.skill +1 -0
package/workspace/lib/kg-cli.js
CHANGED
|
@@ -134,7 +134,8 @@ function run(argv, { say, cwd = process.cwd(), stateDir } = {}) {
|
|
|
134
134
|
say(`${s.nodes} files, ${s.edges} edges (${s.byBasis.stated} stated, ${s.byBasis.mention} mention) — graph ${cached ? 'from cache' : 'rebuilt'}`);
|
|
135
135
|
say('edges by type: ' + Object.entries(s.byType).sort().map(([k, v]) => `${k} ${v}`).join(', '));
|
|
136
136
|
say(`cards: ${s.cardsWritten} written (description), ${s.cardsDerived} derived from the first paragraph, ${s.noCard} none`);
|
|
137
|
-
say(
|
|
137
|
+
say(`typed edges (written under a typed key): ${s.typedEdges} · inferred edges (a D<nn> mention): ${s.inferredEdges}`);
|
|
138
|
+
say(`${s.noType} file(s) have no type (${s.reservedUntyped} reserved index and log files left out) · ${s.noIncoming} have no incoming link (index and log files excluded)`);
|
|
138
139
|
say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
|
|
139
140
|
for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
|
|
140
141
|
say('Only relations written in the files are found. Token counts are characters ÷ 4, an estimate.');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "needs_decision": 0, "why": "knowledge-graph K2 ratchet: the count of dangling links that need a decision may fall, never rise. Lower it here when you fix one; a new broken link must be fixed, or added as history by design in kg-dangling-rules.json with a reason." }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
[
|
|
2
2
|
{ "from": "^okf/log\\.md$", "why": "the OKF log records references to documents that were removed on purpose" },
|
|
3
3
|
{ "from": "^okf/products/pmos/", "to": "(mcp-tools|data-model|references/metrics/v_)", "why": "D66 deleted the hosted-backend reference documents; the text that cites them is history" },
|
|
4
|
+
{ "from": "^planning/prd/", "to": "^/(supabase/README-repo-connect\\.md|okf/products/pmos/references/mcp-tools/link_jira_issue\\.md|okf/core/playbooks/(onboard-existing-repo|tenant-build-kickoff)\\.md)$", "why": "D66 (#294) deleted these hosted-backend documents; the PRD that cites them is history" },
|
|
4
5
|
{ "from": "/blind-plants/", "why": "a blind-plant specimen is planted with broken references on purpose" },
|
|
5
6
|
{ "to": "^/planning/prd/x\\.md$", "why": "an example path in an explainer, not a reference" }
|
|
6
7
|
]
|
package/workspace/lib/kg.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* WHAT IS AN EDGE. Only a relation a file writes down:
|
|
12
12
|
* link a Markdown link to a tracked file basis stated
|
|
13
13
|
* frontmatter a path in prd_path / rubric_path / explainer_path / sources basis stated
|
|
14
|
-
* supersedes · amended_by · cites (typed keys
|
|
14
|
+
* supersedes · amended_by · cites · initiative (typed keys: one-line lists of paths) basis stated, typed
|
|
15
15
|
* initiative initiative_id (frontmatter or an identity bullet) → its record basis stated
|
|
16
16
|
* anchor parent_kr / parent_KR `health:<id>` or a KR id → the file naming it basis stated
|
|
17
17
|
* cites a `D<nn>` mention in prose → that decision's ADR view basis mention
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
const CARD_MAX = 240;
|
|
32
32
|
const ADR_DIR = 'okf/products/pmos/adr/';
|
|
33
33
|
const PATH_KEYS = ['prd_path', 'rubric_path', 'explainer_path', 'sources'];
|
|
34
|
-
const TYPED_KEYS = ['supersedes', 'amended_by', 'cites'];
|
|
34
|
+
const TYPED_KEYS = ['supersedes', 'amended_by', 'cites', 'initiative'];
|
|
35
35
|
const estTokens = (s) => Math.ceil(String(s || '').length / 4);
|
|
36
36
|
|
|
37
37
|
/* Replace fenced code blocks and inline code spans with spaces of the same length, keeping every newline, so line
|
|
@@ -109,12 +109,15 @@
|
|
|
109
109
|
const edges = new Map();
|
|
110
110
|
const dangling = [];
|
|
111
111
|
const meta = { decisionLines: {} };
|
|
112
|
-
const addEdge = (from, to, type, basis) => {
|
|
112
|
+
const addEdge = (from, to, type, basis, typed) => {
|
|
113
113
|
if (from === to || !have.has(to)) return;
|
|
114
114
|
const key = from + '\u0000' + to + '\u0000' + type;
|
|
115
115
|
const cur = edges.get(key);
|
|
116
|
-
if (!cur) edges.set(key, { from, to, type, basis });
|
|
117
|
-
else
|
|
116
|
+
if (!cur) edges.set(key, typed ? { from, to, type, basis, typed: true } : { from, to, type, basis });
|
|
117
|
+
else {
|
|
118
|
+
if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
|
|
119
|
+
if (typed) cur.typed = true;
|
|
120
|
+
}
|
|
118
121
|
};
|
|
119
122
|
|
|
120
123
|
for (const f of list) {
|
|
@@ -144,7 +147,10 @@
|
|
|
144
147
|
if (to) addEdge(f.path, to, 'cites', 'mention');
|
|
145
148
|
}
|
|
146
149
|
for (const k of PATH_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, 'frontmatter', 'stated');
|
|
147
|
-
for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k]))
|
|
150
|
+
for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) {
|
|
151
|
+
if (have.has(p)) addEdge(f.path, p, k, 'stated', true);
|
|
152
|
+
else dangling.push({ from: f.path, to: '/' + p });
|
|
153
|
+
}
|
|
148
154
|
const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
|
|
149
155
|
if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
|
|
150
156
|
const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
|
|
@@ -174,18 +180,24 @@
|
|
|
174
180
|
return { history, needs };
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
/* index.md and log.md are reserved by OKF: they carry no frontmatter, so "no type" is correct for them. */
|
|
184
|
+
const RESERVED = /(^|\/)(index|log)\.md$/;
|
|
185
|
+
|
|
177
186
|
function stats(graph, rules) {
|
|
178
187
|
const byType = {}; const byBasis = { stated: 0, mention: 0 };
|
|
179
188
|
for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
|
|
180
189
|
const inbound = new Set(graph.edges.map((e) => e.to));
|
|
181
|
-
const orphans = graph.nodes.filter((n) => !inbound.has(n.id) &&
|
|
190
|
+
const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !RESERVED.test(n.id)).length;
|
|
182
191
|
const split = splitDangling(graph, rules);
|
|
183
192
|
return {
|
|
184
193
|
nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
|
|
185
194
|
cardsWritten: graph.nodes.filter((n) => n.described).length,
|
|
186
195
|
cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
|
|
187
196
|
noCard: graph.nodes.filter((n) => !n.card).length,
|
|
188
|
-
noType: graph.nodes.filter((n) => !n.type).length,
|
|
197
|
+
noType: graph.nodes.filter((n) => !n.type && !RESERVED.test(n.id)).length,
|
|
198
|
+
reservedUntyped: graph.nodes.filter((n) => !n.type && RESERVED.test(n.id)).length,
|
|
199
|
+
typedEdges: graph.edges.filter((e) => e.typed).length,
|
|
200
|
+
inferredEdges: graph.edges.filter((e) => e.basis === 'mention').length,
|
|
189
201
|
noIncoming: orphans, dangling: graph.dangling.length,
|
|
190
202
|
danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
|
|
191
203
|
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* learn.js — what the proactive engine learns from the PM's feedback (initiative `proactive-engine`, slice D4).
|
|
3
|
+
*
|
|
4
|
+
* Pure: no I/O, no clock, no model, no network. The server reads the ledgers and passes them in; this decides. The weights
|
|
5
|
+
* are arithmetic over the PM's own verdicts. The lessons are written from fixed templates filled with counts, because the
|
|
6
|
+
* workspace server may not call a model. A lesson is a proposal: it changes nothing until the PM approves it.
|
|
7
|
+
*
|
|
8
|
+
* evidence(ledgers) → the PM's verdicts, events and review decisions as signed rows, joined to their item
|
|
9
|
+
* weights(rows) → bounded cells per trigger, per lane, per trigger and time of day
|
|
10
|
+
* adjust(candidate, weights, bucket) → the points added to one item's rank score, with the reason
|
|
11
|
+
* rollup(rows, missed, runs, ctx) → hit rate, used-as-is rate, corrections per prepared run, missed, watermelon check
|
|
12
|
+
* narrowing(rows, restores, now) → triggers left off Today because they were rated badly
|
|
13
|
+
* propose(rows, missed, now, lessons)→ lessons from four templates; none that is waiting, approved or recently rejected
|
|
14
|
+
* foldLessons(events), decideLesson → the lesson ledger and the one place a state change is allowed
|
|
15
|
+
* snapshotDue(snapshots, ctx) → whether the weekly run is due
|
|
16
|
+
*
|
|
17
|
+
* What blocks on the PM (needs, returned) is never lowered and never narrowed.
|
|
18
|
+
*/
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const DAY = 86400000;
|
|
22
|
+
const CAP = Object.freeze({ kind: 8, lane: 4, time: 3, total: 15 });
|
|
23
|
+
const MIN_CELL = 5; // events before a cell moves
|
|
24
|
+
const SHRINK = 5; // a cell moves by sum / (events + 5), less than its raw average
|
|
25
|
+
const MIN_RATED = 5; // rated items before a hit rate is shown
|
|
26
|
+
const MIN_RUNS = 3; // reviewed or prepared runs before a run rate is shown
|
|
27
|
+
const WINDOW_DAYS = 7;
|
|
28
|
+
const NARROW_DAYS = 28;
|
|
29
|
+
const NARROW_MIN = 10;
|
|
30
|
+
const NARROW_FLOOR = 0.30;
|
|
31
|
+
const SNAPSHOT_EVERY_MS = 7 * DAY;
|
|
32
|
+
const REJECT_WAIT_MS = 28 * DAY;
|
|
33
|
+
const MAX_APPROVED = 10;
|
|
34
|
+
const GREEN_HIT = 0.6;
|
|
35
|
+
const PM_BLOCKING = Object.freeze(['needs', 'returned']);
|
|
36
|
+
const TRIGGERS = Object.freeze(['needs', 'returned', 'review', 'schedule', 'stalled']);
|
|
37
|
+
const BUCKETS = Object.freeze(['morning', 'afternoon', 'evening', 'night']);
|
|
38
|
+
|
|
39
|
+
const VALUE = Object.freeze({
|
|
40
|
+
verdict: { useful: 1, 'not-useful': -1, 'wrong-priority': -0.5 },
|
|
41
|
+
event: { acted: 0.5, dismissed: -0.5, opened: 0.25, ignored: -0.25 },
|
|
42
|
+
review: { accept: 1, 'send-back': -1 },
|
|
43
|
+
});
|
|
44
|
+
// Rated = the PM said something about the item itself. Opened and ignored are weak signals: they move a weight, not the hit rate.
|
|
45
|
+
const RATED_VERDICT = ['useful', 'not-useful', 'wrong-priority'];
|
|
46
|
+
const RATED_EVENT = ['acted', 'dismissed'];
|
|
47
|
+
|
|
48
|
+
const LABEL = Object.freeze({ needs: 'Open items waiting on you', returned: 'Returned initiatives', review: 'Sessions awaiting your review', schedule: 'Failed schedules', stalled: 'Stalled initiatives' });
|
|
49
|
+
|
|
50
|
+
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
51
|
+
const pct = (x) => Math.round(x * 100) + '%';
|
|
52
|
+
const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex');
|
|
53
|
+
const r1 = (x) => Math.round(x * 10) / 10;
|
|
54
|
+
const r2 = (x) => Math.round(x * 100) / 100;
|
|
55
|
+
|
|
56
|
+
/** Local time of day, as the PM lived it: morning 05-12, afternoon 12-17, evening 17-22, night otherwise. */
|
|
57
|
+
function bucketOf(ms) {
|
|
58
|
+
const h = new Date(ms).getHours();
|
|
59
|
+
return h >= 5 && h < 12 ? 'morning' : h >= 12 && h < 17 ? 'afternoon' : h >= 17 && h < 22 ? 'evening' : 'night';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {{feedback:Array, signals:Array, reviews:Array, runs:Array, queue:Array, resetAt:number}} l
|
|
64
|
+
* feedback proactive-feedback.jsonl rows signals signals.jsonl rows {id, kind, lane}
|
|
65
|
+
* reviews step-reviews.jsonl rows runs next-step-runs.jsonl rows {session_id, queue_id}
|
|
66
|
+
* queue folded prepare-queue entries {id, candidate_id}
|
|
67
|
+
* @returns {{rows:Array, missed:Array<{at:number,note:string}>}}
|
|
68
|
+
*/
|
|
69
|
+
function evidence(l) {
|
|
70
|
+
const resetAt = Number.isFinite(l.resetAt) ? l.resetAt : 0;
|
|
71
|
+
const sig = new Map();
|
|
72
|
+
for (const s of l.signals || []) if (s && s.id) sig.set(s.id, { kind: TRIGGERS.includes(s.kind) ? s.kind : null, lane: s.lane || null });
|
|
73
|
+
const rows = []; const missed = [];
|
|
74
|
+
for (const f of l.feedback || []) {
|
|
75
|
+
const at = Date.parse(f && f.at);
|
|
76
|
+
if (!Number.isFinite(at) || at <= resetAt) continue;
|
|
77
|
+
if (f.kind === 'verdict' && f.verdict === 'missed') { missed.push({ at, note: clip(f.note, 120) }); continue; }
|
|
78
|
+
const value = f.kind === 'verdict' ? VALUE.verdict[f.verdict] : f.kind === 'event' ? VALUE.event[f.event] : undefined;
|
|
79
|
+
if (value === undefined) continue;
|
|
80
|
+
const rated = f.kind === 'verdict' ? RATED_VERDICT.includes(f.verdict) : RATED_EVENT.includes(f.event);
|
|
81
|
+
const s = f.candidate_id ? sig.get(f.candidate_id) : null;
|
|
82
|
+
rows.push({ at, source: 'feedback', cid: f.candidate_id || null, kind: s ? s.kind : null, lane: s ? s.lane : null, bucket: bucketOf(at), value, rated, matched: !!(s && s.kind) });
|
|
83
|
+
}
|
|
84
|
+
const runBySession = new Map((l.runs || []).filter((r) => r && r.session_id).map((r) => [r.session_id, r]));
|
|
85
|
+
const queueById = new Map((l.queue || []).filter((e) => e && e.id).map((e) => [e.id, e]));
|
|
86
|
+
for (const r of l.reviews || []) {
|
|
87
|
+
const at = Date.parse(r && r.at);
|
|
88
|
+
const value = VALUE.review[r && r.verdict];
|
|
89
|
+
if (!Number.isFinite(at) || at <= resetAt || value === undefined) continue;
|
|
90
|
+
const run = runBySession.get(r.session_id);
|
|
91
|
+
const q = run && run.queue_id ? queueById.get(run.queue_id) : null;
|
|
92
|
+
const s = q && q.candidate_id ? sig.get(q.candidate_id) : null;
|
|
93
|
+
rows.push({ at, source: 'review', cid: q ? q.candidate_id : null, kind: s ? s.kind : null, lane: s ? s.lane : null, bucket: bucketOf(at), value, rated: false, matched: !!(s && s.kind) });
|
|
94
|
+
}
|
|
95
|
+
rows.sort((a, b) => a.at - b.at);
|
|
96
|
+
missed.sort((a, b) => a.at - b.at);
|
|
97
|
+
return { rows, missed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cell(sum, n, cap) {
|
|
101
|
+
if (n < MIN_CELL) return { n, sum: r2(sum), delta: 0 };
|
|
102
|
+
return { n, sum: r2(sum), delta: r2(Math.max(-cap, Math.min(cap, cap * sum / (n + SHRINK)))) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bounded cells. Only an item that was matched to a trigger teaches a weight. */
|
|
106
|
+
function weights(rows) {
|
|
107
|
+
const acc = { kind: new Map(), lane: new Map(), time: new Map() };
|
|
108
|
+
const add = (m, k, v) => { const c = m.get(k) || { n: 0, sum: 0 }; c.n += 1; c.sum += v; m.set(k, c); };
|
|
109
|
+
for (const r of rows || []) {
|
|
110
|
+
if (!r.matched) continue;
|
|
111
|
+
add(acc.kind, r.kind, r.value);
|
|
112
|
+
if (r.lane) add(acc.lane, r.lane, r.value);
|
|
113
|
+
add(acc.time, r.kind + '|' + r.bucket, r.value);
|
|
114
|
+
}
|
|
115
|
+
const out = { kind: {}, lane: {}, time: {} };
|
|
116
|
+
for (const dim of ['kind', 'lane', 'time']) for (const [k, c] of acc[dim]) {
|
|
117
|
+
out[dim][k] = cell(c.sum, c.n, CAP[dim]);
|
|
118
|
+
// What blocks on the PM is never lowered: its own cells keep their counts but never go below zero.
|
|
119
|
+
if (dim !== 'lane' && PM_BLOCKING.includes(k.split('|')[0]) && out[dim][k].delta < 0) { out[dim][k].delta = 0; out[dim][k].floored = true; }
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The points added to one item's rank score. needs and returned are never lowered. `paused` gives zero.
|
|
126
|
+
* @returns {{delta:number, why:string[]}}
|
|
127
|
+
*/
|
|
128
|
+
function adjust(c, w, bucket, paused) {
|
|
129
|
+
if (paused || !w || !c) return { delta: 0, why: [] };
|
|
130
|
+
const parts = [];
|
|
131
|
+
const k = w.kind[c.kind]; if (k && k.delta) parts.push({ d: k.delta, t: `${c.kind} items ${k.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(k.delta)} (${k.n} ratings, net ${k.sum})` });
|
|
132
|
+
const ln = c.lane && w.lane[c.lane]; if (ln && ln.delta) parts.push({ d: ln.delta, t: `${c.lane} lane ${ln.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(ln.delta)} (${ln.n} ratings, net ${ln.sum})` });
|
|
133
|
+
const tm = w.time[c.kind + '|' + bucket]; if (tm && tm.delta) parts.push({ d: tm.delta, t: `${c.kind} items in the ${bucket} ${tm.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(tm.delta)} (${tm.n} ratings, net ${tm.sum})` });
|
|
134
|
+
let delta = Math.max(-CAP.total, Math.min(CAP.total, parts.reduce((a, p) => a + p.d, 0)));
|
|
135
|
+
if (PM_BLOCKING.includes(c.kind) && delta < 0) return { delta: 0, why: ['what blocks on you is never lowered'] };
|
|
136
|
+
return { delta: r1(delta), why: parts.map((p) => p.t) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function perKind(rows, now, days) {
|
|
140
|
+
const from = now - days * DAY; const out = {};
|
|
141
|
+
for (const r of rows) {
|
|
142
|
+
if (!r.matched || r.source !== 'feedback' || !r.rated || r.at <= from) continue;
|
|
143
|
+
const c = out[r.kind] || (out[r.kind] = { n: 0, positive: 0 });
|
|
144
|
+
c.n += 1; if (r.value > 0) c.positive += 1;
|
|
145
|
+
}
|
|
146
|
+
for (const c of Object.values(out)) c.rate = c.n >= MIN_RATED ? r2(c.positive / c.n) : null;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {Array} rows @param {Array<{at:number,note:string}>} missed @param {number[]} preparedAt next-step run times
|
|
152
|
+
* @param {{now:number, days?:number}} ctx
|
|
153
|
+
*/
|
|
154
|
+
function rollup(rows, missed, preparedAt, ctx) {
|
|
155
|
+
const days = ctx.days || WINDOW_DAYS; const from = ctx.now - days * DAY;
|
|
156
|
+
const win = (rows || []).filter((r) => r.at > from);
|
|
157
|
+
const fb = win.filter((r) => r.source === 'feedback' && r.rated);
|
|
158
|
+
const rev = win.filter((r) => r.source === 'review');
|
|
159
|
+
const prepared = (preparedAt || []).filter((t) => t > from).length;
|
|
160
|
+
const sendBacks = rev.filter((r) => r.value < 0).length;
|
|
161
|
+
const positive = fb.filter((r) => r.value > 0).length;
|
|
162
|
+
const accepted = rev.filter((r) => r.value > 0).length;
|
|
163
|
+
const missedN = (missed || []).filter((m) => m.at > from).length;
|
|
164
|
+
const hit = { n: fb.length, positive, rate: fb.length >= MIN_RATED ? r2(positive / fb.length) : null, min: MIN_RATED };
|
|
165
|
+
const asIs = { n: rev.length, accepted, rate: rev.length >= MIN_RUNS ? r2(accepted / rev.length) : null, min: MIN_RUNS };
|
|
166
|
+
const corrections = { n: prepared, sendBacks, rate: prepared >= MIN_RUNS ? r2(sendBacks / prepared) : null, min: MIN_RUNS };
|
|
167
|
+
// Watermelon: green on the headline, red underneath. Only checked when the headline is measurable and green.
|
|
168
|
+
const reasons = [];
|
|
169
|
+
const checked = hit.rate !== null && hit.rate >= GREEN_HIT;
|
|
170
|
+
if (checked) {
|
|
171
|
+
if (missedN >= 2) reasons.push(`${missedN} items were reported as missed`);
|
|
172
|
+
if (asIs.rate !== null && asIs.rate < 0.5) reasons.push(`only ${accepted} of ${rev.length} reviewed runs were used as they were`);
|
|
173
|
+
if (corrections.rate !== null && corrections.rate >= 0.5) reasons.push(`${sendBacks} of ${prepared} prepared runs were sent back`);
|
|
174
|
+
}
|
|
175
|
+
return { days, since: new Date(from).toISOString(), hit, asIs, corrections, missed: missedN, perKind: perKind(rows || [], ctx.now, days),
|
|
176
|
+
unmatched: fb.filter((r) => !r.matched).length,
|
|
177
|
+
watermelon: { checked, flag: reasons.length > 0, reasons, note: checked ? (reasons.length ? 'The hit rate looks good, and something under it does not.' : 'Nothing under the hit rate contradicts it.') : hit.rate === null ? `Not checked: fewer than ${MIN_RATED} rated items.` : `Not checked: the hit rate is under ${GREEN_HIT}, so there is no green to look under.` } };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Triggers left off Today. needs and returned never appear here. @param {Array<{kind:string, at:number}>} restores */
|
|
181
|
+
function narrowing(rows, restores, now) {
|
|
182
|
+
const last = {};
|
|
183
|
+
for (const r of restores || []) if (r && r.kind && Number.isFinite(r.at)) last[r.kind] = Math.max(last[r.kind] || 0, r.at);
|
|
184
|
+
const from = now - NARROW_DAYS * DAY; const out = [];
|
|
185
|
+
for (const kind of TRIGGERS) {
|
|
186
|
+
if (PM_BLOCKING.includes(kind)) continue;
|
|
187
|
+
let n = 0; let pos = 0;
|
|
188
|
+
for (const r of rows || []) {
|
|
189
|
+
if (!r.matched || r.source !== 'feedback' || !r.rated || r.kind !== kind || r.at <= from || r.at <= (last[kind] || 0)) continue;
|
|
190
|
+
n += 1; if (r.value > 0) pos += 1;
|
|
191
|
+
}
|
|
192
|
+
if (n >= NARROW_MIN && pos / n < NARROW_FLOOR) out.push({ kind, n, positive: pos, rate: r2(pos / n), floor: NARROW_FLOOR, days: NARROW_DAYS, label: LABEL[kind] });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── lessons ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
/** The lesson ledger, folded. Events: proposed {id,key,text,evidence,at}; approved | rejected | retired {id,at}. */
|
|
200
|
+
function foldLessons(events) {
|
|
201
|
+
const by = new Map();
|
|
202
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
203
|
+
if (!e || typeof e.id !== 'string') continue;
|
|
204
|
+
if (e.event === 'proposed') { if (!by.has(e.id) && e.key && e.text) by.set(e.id, { id: e.id, key: e.key, text: e.text, evidence: e.evidence || '', status: 'proposed', proposed_at: e.at, at: e.at }); continue; }
|
|
205
|
+
const cur = by.get(e.id);
|
|
206
|
+
if (!cur) continue;
|
|
207
|
+
if (e.event === 'approved' && cur.status === 'proposed') { cur.status = 'approved'; cur.at = e.at; }
|
|
208
|
+
else if (e.event === 'rejected' && cur.status === 'proposed') { cur.status = 'rejected'; cur.at = e.at; }
|
|
209
|
+
else if (e.event === 'retired' && cur.status === 'approved') { cur.status = 'retired'; cur.at = e.at; }
|
|
210
|
+
}
|
|
211
|
+
return [...by.values()];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @returns {{ok:true, req:object}|{ok:false, error:string}} */
|
|
215
|
+
function checkLessonRequest(body) {
|
|
216
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
217
|
+
if (b.confirm !== true) return { ok: false, error: 'confirm is required: approving a lesson lets the morning-brief session read it' };
|
|
218
|
+
if (typeof b.id !== 'string' || !/^ls-[0-9a-f]{10}$/.test(b.id)) return { ok: false, error: 'id is required and must be a lesson id' };
|
|
219
|
+
if (!['approve', 'reject', 'retire'].includes(b.decision)) return { ok: false, error: 'decision must be approve, reject or retire' };
|
|
220
|
+
return { ok: true, req: { id: b.id, decision: b.decision } };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @returns {string|null} the reason a decision is refused, or null */
|
|
224
|
+
function decideLesson(lessons, req) {
|
|
225
|
+
const l = (lessons || []).find((x) => x.id === req.id);
|
|
226
|
+
if (!l) return 'no such lesson';
|
|
227
|
+
if (req.decision === 'retire') return l.status === 'approved' ? null : `it is ${l.status} — only an approved lesson can be retired`;
|
|
228
|
+
if (l.status !== 'proposed') return `it is already ${l.status} — only a proposed lesson can be ${req.decision}d`;
|
|
229
|
+
if (req.decision === 'approve' && lessons.filter((x) => x.status === 'approved').length >= MAX_APPROVED) return `${MAX_APPROVED} lessons are already approved (the limit). Retire one first.`;
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function lessonId(key, at) { return 'ls-' + hash(key + '|' + at).slice(0, 10); }
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Lessons from four fixed templates, filled with counts. A key already proposed or approved is skipped, and so is a key the
|
|
237
|
+
* PM rejected inside the last 28 days. @returns {Array<{key:string, text:string, evidence:string}>}
|
|
238
|
+
*/
|
|
239
|
+
function propose(rows, missed, now, lessons) {
|
|
240
|
+
const from = now - WINDOW_DAYS * DAY;
|
|
241
|
+
const win = (rows || []).filter((r) => r.at > from && r.matched && r.source === 'feedback' && r.rated);
|
|
242
|
+
const out = [];
|
|
243
|
+
const byKind = {}; const byCell = {};
|
|
244
|
+
for (const r of win) {
|
|
245
|
+
const k = byKind[r.kind] || (byKind[r.kind] = { n: 0, pos: 0 }); k.n += 1; if (r.value > 0) k.pos += 1;
|
|
246
|
+
const ck = r.kind + '|' + r.bucket; const c = byCell[ck] || (byCell[ck] = { n: 0, pos: 0 }); c.n += 1; if (r.value > 0) c.pos += 1;
|
|
247
|
+
}
|
|
248
|
+
for (const kind of TRIGGERS) {
|
|
249
|
+
const k = byKind[kind]; if (!k || k.n < MIN_RATED) continue;
|
|
250
|
+
const rate = k.pos / k.n;
|
|
251
|
+
if (rate < 0.4) out.push({ key: 'kind-low:' + kind, text: `${LABEL[kind]} were useful ${pct(rate)} of the time this week (${k.pos} of ${k.n} ratings). Suggest: put them under worth-asking, not priorities, in the morning brief.`, evidence: `${k.pos} of ${k.n} ratings positive, ${WINDOW_DAYS} days` });
|
|
252
|
+
else if (rate >= 0.8) out.push({ key: 'kind-high:' + kind, text: `${LABEL[kind]} were useful ${pct(rate)} of the time this week (${k.pos} of ${k.n} ratings). Suggest: treat them as priorities in the morning brief.`, evidence: `${k.pos} of ${k.n} ratings positive, ${WINDOW_DAYS} days` });
|
|
253
|
+
for (const b of BUCKETS) {
|
|
254
|
+
const c = byCell[kind + '|' + b]; if (!c || c.n < MIN_RATED) continue;
|
|
255
|
+
const cr = c.pos / c.n;
|
|
256
|
+
if (cr < 0.3 && rate >= 0.5) out.push({ key: `kind-time:${kind}:${b}`, text: `${LABEL[kind]} shown in the ${b} were useful ${pct(cr)} of the time (${c.pos} of ${c.n}), against ${pct(rate)} over the whole day. Suggest: hold them until another part of the day.`, evidence: `${c.pos} of ${c.n} in the ${b}; ${k.pos} of ${k.n} overall` });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const notes = (missed || []).filter((m) => m.at > from && m.note).map((m) => m.note);
|
|
260
|
+
if (notes.length >= 2) {
|
|
261
|
+
const shown = notes.slice(0, 3).map((n) => `"${clip(n, 60)}"`).join('; ');
|
|
262
|
+
out.push({ key: 'missed:' + hash(notes.slice().sort().join('|')).slice(0, 10), text: clip(`You reported ${notes.length} missed items this week: ${shown}. Suggest: look for things like these when preparing the morning brief.`, 300), evidence: `${notes.length} missed notes, ${WINDOW_DAYS} days` });
|
|
263
|
+
}
|
|
264
|
+
const live = new Set(); const wait = new Map();
|
|
265
|
+
for (const l of lessons || []) {
|
|
266
|
+
if (l.status === 'proposed' || l.status === 'approved') live.add(l.key);
|
|
267
|
+
if (l.status === 'rejected') wait.set(l.key, Math.max(wait.get(l.key) || 0, Date.parse(l.at) || 0));
|
|
268
|
+
}
|
|
269
|
+
return out.filter((p) => !live.has(p.key) && !(wait.has(p.key) && now - wait.get(p.key) < REJECT_WAIT_MS));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The weekly run is due when the last snapshot is 7 days old, or when there is none and there is evidence. */
|
|
273
|
+
function snapshotDue(snapshots, ctx) {
|
|
274
|
+
const last = (snapshots || []).map((s) => Date.parse(s && s.at)).filter(Number.isFinite).sort((a, b) => a - b).pop();
|
|
275
|
+
if (last === undefined) return !!ctx.hasEvidence;
|
|
276
|
+
return ctx.now - last >= SNAPSHOT_EVERY_MS;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { CAP, MIN_CELL, MIN_RATED, MIN_RUNS, WINDOW_DAYS, NARROW_DAYS, NARROW_MIN, NARROW_FLOOR, SNAPSHOT_EVERY_MS, REJECT_WAIT_MS, MAX_APPROVED, GREEN_HIT,
|
|
280
|
+
PM_BLOCKING, TRIGGERS, BUCKETS, VALUE, LABEL, bucketOf, evidence, weights, adjust, rollup, narrowing, foldLessons, checkLessonRequest, decideLesson, lessonId, propose, snapshotDue };
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* pmos proactive prepare plan --initiative ID [--repo PATH]
|
|
17
17
|
* pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
|
|
18
18
|
* pmos proactive prepare status | cancel ID | run
|
|
19
|
+
* pmos proactive learn [status] | run | lessons | approve ID | reject ID | retire ID | restore TRIGGER | reset
|
|
19
20
|
*/
|
|
20
21
|
const fs = require('fs');
|
|
21
22
|
const path = require('path');
|
|
22
23
|
const { execFileSync } = require('child_process');
|
|
23
24
|
const { parseArgs } = require('./schedule-cli');
|
|
24
25
|
|
|
25
|
-
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare'] };
|
|
26
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare', 'learn'] };
|
|
26
27
|
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
27
28
|
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
28
29
|
|
|
@@ -141,6 +142,53 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
141
142
|
for (const e of r.entries) say(`${e.id} ${e.status} ${e.label}${e.reason ? ' — ' + e.reason : ''}`);
|
|
142
143
|
return 0;
|
|
143
144
|
}
|
|
145
|
+
if (verb === 'learn') {
|
|
146
|
+
const sub = pos[0] || 'status';
|
|
147
|
+
const rate = (x, what) => (x.rate === null ? `${what}: not enough evidence (${x.n} of ${x.min} needed)` : `${what}: ${Math.round(x.rate * 100)}% (${x.n} counted)`);
|
|
148
|
+
const decide = async (decision, past) => {
|
|
149
|
+
if (!pos[1]) return fail(`usage: pmos proactive learn ${decision} ID`);
|
|
150
|
+
const r = await call('POST', '/api/proactive/learn/lesson', { id: pos[1], decision, confirm: true });
|
|
151
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
152
|
+
say(`${past} ${r.id}`); return 0;
|
|
153
|
+
};
|
|
154
|
+
if (sub === 'approve') return decide('approve', 'approved');
|
|
155
|
+
if (sub === 'reject') return decide('reject', 'rejected');
|
|
156
|
+
if (sub === 'retire') return decide('retire', 'retired');
|
|
157
|
+
if (sub === 'restore') {
|
|
158
|
+
if (!pos[1]) return fail('usage: pmos proactive learn restore TRIGGER');
|
|
159
|
+
const r = await call('POST', '/api/proactive/learn/restore', { kind: pos[1], confirm: true });
|
|
160
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
161
|
+
say(`restored ${r.kind} — it comes back on Today and stays until 10 new ratings of it have arrived`); return 0;
|
|
162
|
+
}
|
|
163
|
+
if (sub === 'reset') {
|
|
164
|
+
const r = await call('POST', '/api/proactive/learn/reset', { confirm: true });
|
|
165
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
166
|
+
say('reset — earlier feedback is ignored from now on. It is not deleted.'); return 0;
|
|
167
|
+
}
|
|
168
|
+
if (sub === 'run') {
|
|
169
|
+
const r = await call('POST', '/api/proactive/learn/run', {});
|
|
170
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}`);
|
|
171
|
+
say(`weekly review taken — ${r.proposed.length} lesson(s) proposed${r.snapshot.narrowed.length ? ', narrowed: ' + r.snapshot.narrowed.join(', ') : ''}`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const r = await call('GET', '/api/proactive/learn');
|
|
175
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
176
|
+
if (sub === 'lessons') {
|
|
177
|
+
if (r.paused) { say('Learning is paused: no lesson applies.'); return 0; }
|
|
178
|
+
if (!r.approved.length) say('No approved lessons.');
|
|
179
|
+
for (const l of r.approved) say(`- ${l.text}`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
if (sub !== 'status') return fail('usage: pmos proactive learn [status|run|lessons|approve ID|reject ID|retire ID|restore TRIGGER|reset]');
|
|
183
|
+
say(`learning ${r.paused ? 'paused' : 'on'} · ${r.evidenceCount} piece(s) of evidence${r.resetAt ? ' since ' + r.resetAt : ''}`);
|
|
184
|
+
const ro = r.rollup;
|
|
185
|
+
say(`last ${ro.days} days — ${rate(ro.hit, 'hit rate')} · ${rate(ro.asIs, 'used as is')} · ${rate(ro.corrections, 'corrections per prepared run')} · ${ro.missed} reported missed`);
|
|
186
|
+
say('watermelon check: ' + ro.watermelon.note + (ro.watermelon.reasons.length ? ' ' + ro.watermelon.reasons.join('; ') + '.' : ''));
|
|
187
|
+
for (const c of r.cells.filter((x) => x.delta)) say(`weight ${c.dim} ${c.key}: ${c.delta > 0 ? '+' : ''}${c.delta} (${c.n} events, net ${c.sum})`);
|
|
188
|
+
for (const n of r.narrowed) say(`narrowed: ${n.kind} — useful ${Math.round(n.rate * 100)}% over ${n.n} ratings in ${n.days} days (floor ${Math.round(n.floor * 100)}%). Restore with: pmos proactive learn restore ${n.kind}`);
|
|
189
|
+
for (const l of r.lessons.filter((x) => x.status === 'proposed' || x.status === 'approved')) say(`${l.id} ${l.status} ${l.text}`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
144
192
|
if (verb === 'brief') {
|
|
145
193
|
const sub = pos[0] || 'show';
|
|
146
194
|
if (sub === 'submit') {
|
|
@@ -39,14 +39,14 @@ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().sl
|
|
|
39
39
|
const days = (ms) => Math.floor(ms / DAY);
|
|
40
40
|
|
|
41
41
|
function cand(kind, key, p) {
|
|
42
|
-
return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null,
|
|
42
|
+
return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null, lane: p.lane || null,
|
|
43
43
|
title: clip(p.title, 120), why: clip(p.why, 200), evidence: clip(p.evidence, 240), confidence: p.confidence, since: p.since == null ? null : p.since,
|
|
44
44
|
open: p.open || null };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* @param {{now:number, settings:object, repos:Array, nextStepRuns:Array}} ctx
|
|
49
|
-
* repos[]: {name, ws, error?, initiatives:[{id,title,active,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
49
|
+
* repos[]: {name, ws, error?, initiatives:[{id,title,active,lane,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
50
50
|
* returned:[{initiative_id,note,at}], schedules:[{id,name,lastStatus,lastRun}]}
|
|
51
51
|
* nextStepRuns[]: {session_id, initiative_id, ws, key, ended, reviewed, at}
|
|
52
52
|
* @returns {{candidates:Array, unreadable:Array<{repo:string,error:string}>}}
|
|
@@ -61,7 +61,7 @@ function detect(ctx) {
|
|
|
61
61
|
for (const n of r.needs || []) {
|
|
62
62
|
const ini = byId.get(n.initiative_id);
|
|
63
63
|
out.push(cand('needs', `needs:${r.ws}:${n.run_id || n.initiative_id + ':' + clip(n.question, 60)}`, {
|
|
64
|
-
initiative_id: n.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
|
|
64
|
+
initiative_id: n.initiative_id, ws: r.ws, repo: r.name, lane: ini ? ini.lane : null, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
|
|
65
65
|
why: 'An open item is waiting on you.', evidence: n.question, confidence: 0.95, since: null,
|
|
66
66
|
open: n.initiative_id ? { type: 'initiative', initiative_id: n.initiative_id, ws: r.ws } : null }));
|
|
67
67
|
}
|
|
@@ -71,7 +71,7 @@ function detect(ctx) {
|
|
|
71
71
|
const ini = byId.get(x.initiative_id);
|
|
72
72
|
if (ini && !ini.active) continue;
|
|
73
73
|
out.push(cand('returned', `returned:${r.ws}:${x.initiative_id}:${x.at || ''}`, {
|
|
74
|
-
initiative_id: x.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : x.initiative_id,
|
|
74
|
+
initiative_id: x.initiative_id, ws: r.ws, repo: r.name, lane: ini ? ini.lane : null, title: ini ? ini.title || ini.id : x.initiative_id,
|
|
75
75
|
why: 'You returned this at the Acceptance Gate and it has not been accepted since.', evidence: x.note || 'returned', confidence: 0.95, since: x.at ? Date.parse(x.at) || null : null,
|
|
76
76
|
open: { type: 'initiative', initiative_id: x.initiative_id, ws: r.ws } }));
|
|
77
77
|
}
|
|
@@ -93,7 +93,7 @@ function detect(ctx) {
|
|
|
93
93
|
if (idle < limit) continue;
|
|
94
94
|
const d = days(idle);
|
|
95
95
|
out.push(cand('stalled', `stalled:${r.ws}:${i.id}`, {
|
|
96
|
-
initiative_id: i.id, ws: r.ws, repo: r.name, title: i.title || i.id,
|
|
96
|
+
initiative_id: i.id, ws: r.ws, repo: r.name, lane: i.lane, title: i.title || i.id,
|
|
97
97
|
why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${st.stalledDays || DEFAULTS.stalledDays} days`,
|
|
98
98
|
confidence: Math.min(0.85, +(0.5 + 0.05 * (d - (st.stalledDays || DEFAULTS.stalledDays))).toFixed(2)), since: i.lastRun,
|
|
99
99
|
open: { type: 'initiative', initiative_id: i.id, ws: r.ws } }));
|
|
@@ -112,9 +112,10 @@ function detect(ctx) {
|
|
|
112
112
|
return { candidates: out, unreadable };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
/** `adj` is optional (slice D4): c → {delta, why[]}. Without it the order is the fixed one, unchanged. */
|
|
116
|
+
function rank(cands, now, adj) {
|
|
116
117
|
const score = (c) => BASE[c.kind] + (c.since != null && now != null ? Math.min(30, Math.max(0, days(now - c.since))) : 0);
|
|
117
|
-
return cands.map((c) => ({ ...c, rank: score(c) })
|
|
118
|
+
return cands.map((c) => { const a = typeof adj === 'function' ? adj(c) : null; const d = a && a.delta ? a.delta : 0; return { ...c, rank: score(c) + d, adjust: d, adjust_why: a && a.why ? a.why.join('; ') : '' }; }).sort((a, b) => b.rank - a.rank || (a.id < b.id ? -1 : 1))
|
|
118
119
|
.map((c, i) => ({ ...c, n: i + 1 }));
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// session-flag — the one variable the workspace puts in the environment of each agent terminal it
|
|
3
|
+
// starts, so a Claude Code mod running inside can tell it is hosted (pmos-cockpit-mod, Run 4, M12).
|
|
4
|
+
//
|
|
5
|
+
// PMOS_WORKSPACE=1
|
|
6
|
+
//
|
|
7
|
+
// It is set in exactly two places, one per way a terminal starts, and nowhere else:
|
|
8
|
+
// · the direct PTY path — `withFlag(env)` over the merged session environment
|
|
9
|
+
// · the tmux path — `tmuxOverlay(o)` over the `-e K=V` overlay that travels to the pane
|
|
10
|
+
//
|
|
11
|
+
// It is NEVER put in `sessionEnv()` itself. That base environment also seeds tmux control commands
|
|
12
|
+
// (a cold start seeds the daemon, and so every pane on the socket) and a git sync process, which are
|
|
13
|
+
// not agent terminals. And it is applied LAST, after a profile's overlay, so a profile cannot unset
|
|
14
|
+
// or change it: the workspace owns this variable.
|
|
15
|
+
//
|
|
16
|
+
// The flag changes what the mod DRAWS (it hides the slice, which the workspace already shows). It
|
|
17
|
+
// never changes what the mod GUARDS. A terminal started outside the workspace has no such flag.
|
|
18
|
+
//
|
|
19
|
+
// Pure: no I/O, no process state.
|
|
20
|
+
|
|
21
|
+
const FLAG = 'PMOS_WORKSPACE';
|
|
22
|
+
const VALUE = '1';
|
|
23
|
+
|
|
24
|
+
/** The direct PTY path: set the flag on a session environment, last. Returns the same object. */
|
|
25
|
+
function withFlag(env) {
|
|
26
|
+
env[FLAG] = VALUE;
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tmux path: the overlay that becomes `new-session -e K=V`, with the flag last. A new object. */
|
|
31
|
+
function tmuxOverlay(overlay) {
|
|
32
|
+
return { ...(overlay || {}), [FLAG]: VALUE };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { FLAG, VALUE, withFlag, tmuxOverlay };
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js",
|
|
13
|
+
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js && node learn.test.js && node session-flag.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js && node pm-r26-test.js",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|