create-pmos 0.168.0 → 0.170.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-driver-fixture.js +61 -0
- package/workspace/c3-driver-test.js +164 -0
- package/workspace/c3-scan-fixture.js +78 -0
- package/workspace/c3-scan-test.js +170 -0
- package/workspace/guide/horizon-scan.md +73 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +12 -0
- package/workspace/lib/engine/boards.js +8 -2
- package/workspace/lib/headless.js +21 -3
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +30 -1
- package/workspace/lib/routes-driver.js +38 -6
- package/workspace/lib/routes-scan.js +130 -0
- package/workspace/lib/scan-cli.js +226 -0
- package/workspace/lib/scan-run.js +186 -0
- package/workspace/lib/scan.js +396 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-needs.js +19 -3
- package/workspace/public/pm/c3-driver-work.js +110 -0
- package/workspace/public/pm/c3-driver.css +18 -0
- package/workspace/public/pm/c3-driver.js +298 -0
- package/workspace/public/pm/c3-ib-triage.js +6 -2
- package/workspace/public/pm/c3-ii-tabs.js +3 -2
- package/workspace/public/pm/c3-ini-ui.js +4 -2
- package/workspace/public/pm/c3-initiatives.js +21 -0
- package/workspace/public/pm/c3-scan.css +25 -0
- package/workspace/public/pm/c3-scan.js +263 -0
- package/workspace/public/pm/c3-shell.js +3 -2
- package/workspace/public/pm/pm-work-view.js +12 -2
- package/workspace/server.js +40 -4
- package/workspace/skills/horizon-scan-repo.skill +56 -0
- package/workspace/skills/horizon-scan-web.skill +43 -0
- package/workspace/test-files.json +8 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* scan.js — the Horizon scan's ledger and rules (initiative horizon-scan, PRD sections 1 to 5).
|
|
3
|
+
*
|
|
4
|
+
* The agent looks outward on a schedule. A scan is two headless runs: a web run that sees only the watch list the PM kept, and a
|
|
5
|
+
* repository run that sees the repository and files up to 3 findings of its own. Each finding becomes a Triage ask that the PM
|
|
6
|
+
* accepts or declines. This file is the pure core: the shape of every line in <state folder>/scan.jsonl, the fold, the schedule and
|
|
7
|
+
* the duplicate test. It reads the clock only through the `now` its callers pass. The only I/O is the read/append pair at the bottom.
|
|
8
|
+
*
|
|
9
|
+
* LEDGER · KINDS · CADENCES · DEFAULT_CADENCE · EVENTS · LIMITS · SLOT
|
|
10
|
+
* ledgerFile(stateDir) → <state folder>/scan.jsonl
|
|
11
|
+
* append(file, line, { now }) → the line written. THE SINGLE WRITER of scan.jsonl.
|
|
12
|
+
* readLines(file) → every line that parses, in file order
|
|
13
|
+
* fold(lines) → the current state (boards with cadence and watch list, scans, findings)
|
|
14
|
+
* watchOf(state, board) · keptItems(state, board) · cadenceOf(state, board)
|
|
15
|
+
* checkWatchItem(input) · checkFinding(input) · sourceOf(ref)
|
|
16
|
+
* words(title) · normUrl(url) · duplicateOf(state, board, { title, sources }) the duplicate test (PRD section 3)
|
|
17
|
+
* isWorkingDay(ms, cadence) · scanDay(state, board, day) · dueNow(...) · suggestDue(...) · nextScan(...)
|
|
18
|
+
* setCadence(file, board, cadence, actor) · watchOp(file, op, board, input, actor) the PM's presses (routes and CLI)
|
|
19
|
+
* scheduleText(cadence) → the plain sentence the settings block shows
|
|
20
|
+
*
|
|
21
|
+
* THE LINES. Every line is { v: 1, ev, …, at }. Who wrote a decision is the call path (lib/actor.js), never a field:
|
|
22
|
+
* watch-suggest { board, id, kind, name, why, ini } the suggest run files up to 8; `ini` names the initiative it came from
|
|
23
|
+
* watch-keep { board, id, restore? } PM only. `restore` brings a removed item back as it was (the Undo)
|
|
24
|
+
* watch-edit { board, id, name?, kind?, why? } PM only; an edited item is kept
|
|
25
|
+
* watch-remove { board, id } PM only
|
|
26
|
+
* watch-add { board, id, kind, name, why? } PM only; an added item is kept
|
|
27
|
+
* cadence { board, cadence } PM only: daily | weekly | off
|
|
28
|
+
* scan-start { board, scan, mode: 'scan'|'suggest', watch: [{ id, kind, name, why }] }
|
|
29
|
+
* scan-end { board, scan, ok, reason?, pages, found, skipped, runs }
|
|
30
|
+
* finding { board, scan, id, origin: 'web'|'repo', title, why, sources: [{ label, ref }], match, by, run? } `run` is the scan run that filed it
|
|
31
|
+
* A later line with the same `id` and a `match` sets the match (the repository run marks the web findings).
|
|
32
|
+
* Three lines the PRD does not list, each needed by one rule it does:
|
|
33
|
+
* skip { board, scan, origin, of } a finding the duplicate test refused: "the skip count is recorded on the scan"
|
|
34
|
+
* pages { board, scan, pages } the number of public pages the web run says it read
|
|
35
|
+
* run-end { board, scan, run, origin: 'web'|'repo'|'suggest', ok, reason?, agent_run } "each scan run writes an agent-runs line"
|
|
36
|
+
*
|
|
37
|
+
* THE FACTORY NEVER WRITES HERE (D65): this ledger is not an initiative, an acceptance or an anchor. A finding is a proposal: the
|
|
38
|
+
* Triage ask it becomes (lib/routes-boards.js appendScanAsk) turns into an initiative only when the PM accepts it.
|
|
39
|
+
*/
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const crypto = require('crypto');
|
|
43
|
+
const D = require('./driver.js');
|
|
44
|
+
|
|
45
|
+
const LEDGER = 'scan.jsonl';
|
|
46
|
+
const KINDS = Object.freeze(['product', 'domain', 'approach']);
|
|
47
|
+
const CADENCES = Object.freeze(['daily', 'weekly', 'off']);
|
|
48
|
+
const DEFAULT_CADENCE = 'daily';
|
|
49
|
+
const EVENTS = Object.freeze(['watch-suggest', 'watch-keep', 'watch-edit', 'watch-remove', 'watch-add', 'cadence', 'scan-start', 'scan-end', 'finding', 'skip', 'pages', 'run-end']);
|
|
50
|
+
const LIMITS = Object.freeze({ suggestions: 8, web: 5, repo: 3, sources: 6, items: 40 });
|
|
51
|
+
const SLOT = '09:00';
|
|
52
|
+
const ORIGINS = Object.freeze(['web', 'repo']);
|
|
53
|
+
const MODES = Object.freeze(['scan', 'suggest']);
|
|
54
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
55
|
+
const WATCH_RE = /^w-[0-9a-f]{8}$/;
|
|
56
|
+
const SCAN_RE = /^scan-[0-9a-f]{10}$/;
|
|
57
|
+
const FINDING_RE = /^f-[0-9a-f]{10}$/;
|
|
58
|
+
const RUN_RE = /^srun-[0-9a-f]{10}$/;
|
|
59
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
60
|
+
const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
|
|
61
|
+
const pm = (by) => typeof by === 'string' && by.startsWith('pm:');
|
|
62
|
+
const newId = (prefix, bytes = 4) => `${prefix}-${crypto.randomBytes(bytes).toString('hex')}`;
|
|
63
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
64
|
+
|
|
65
|
+
// ── the one writer, and the read ─────────────────────────────────────────────────────────────────────
|
|
66
|
+
const ledgerFile = (stateDir) => (stateDir ? path.join(stateDir, LEDGER) : null);
|
|
67
|
+
/** THE SINGLE WRITER of scan.jsonl. `v` and `at` are set here; a caller never chooses them. */
|
|
68
|
+
function append(file, line, { now } = {}) {
|
|
69
|
+
if (!file) throw new Error('scan: no ledger file');
|
|
70
|
+
if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`scan: unknown event ${JSON.stringify(line && line.ev)}`);
|
|
71
|
+
const out = { ...line, v: 1, at: new Date(now == null ? Date.now() : now).toISOString() };
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
fs.appendFileSync(file, JSON.stringify(out) + '\n');
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function readLines(file) {
|
|
77
|
+
let text = '';
|
|
78
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const raw of text.split('\n')) {
|
|
81
|
+
if (!raw.trim()) continue;
|
|
82
|
+
try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── shapes ───────────────────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
const isUrl = (s) => /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(s || ''));
|
|
89
|
+
const JIRA_RE = /^[A-Z][A-Z0-9_]+-\d+$/;
|
|
90
|
+
/** The kind of a source pointer: a public page, a Jira key, or a path or note. */
|
|
91
|
+
const sourceOf = (ref) => (isUrl(ref) ? 'web' : JIRA_RE.test(String(ref || '').trim()) ? 'jira' : 'note');
|
|
92
|
+
|
|
93
|
+
function checkWatchItem(i) {
|
|
94
|
+
const x = isObj(i) ? i : {};
|
|
95
|
+
if (!KINDS.includes(x.kind)) return { ok: false, error: `kind must be one of ${KINDS.join(', ')}` };
|
|
96
|
+
if (!oneLine(x.name, 80)) return { ok: false, error: 'name is one line of 1 to 80 characters' };
|
|
97
|
+
if (x.why != null && x.why !== '' && !oneLine(x.why, 200)) return { ok: false, error: 'why is one line of at most 200 characters' };
|
|
98
|
+
return { ok: true, item: { kind: x.kind, name: x.name.trim(), why: typeof x.why === 'string' ? x.why.trim() : '' } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The shape of one finding as an agent files it. A web finding needs a public page it read; a repository finding needs a pointer. */
|
|
102
|
+
function checkFinding(i, origin) {
|
|
103
|
+
const x = isObj(i) ? i : {};
|
|
104
|
+
if (!ORIGINS.includes(origin)) return { ok: false, error: 'origin is web or repo' };
|
|
105
|
+
if (!oneLine(x.title, 160)) return { ok: false, error: 'title is one line of 1 to 160 characters' };
|
|
106
|
+
if (!oneLine(x.why, 300)) return { ok: false, error: 'why is one line of 1 to 300 characters' };
|
|
107
|
+
const list = Array.isArray(x.sources) ? x.sources : [];
|
|
108
|
+
if (!list.length) return { ok: false, error: origin === 'web' ? 'a web finding needs at least one source: the public page you read' : 'a repository finding needs at least one pointer (a path, a Jira key or a note)' };
|
|
109
|
+
if (list.length > LIMITS.sources) return { ok: false, error: `at most ${LIMITS.sources} sources` };
|
|
110
|
+
const sources = [];
|
|
111
|
+
for (const s of list) {
|
|
112
|
+
if (!isObj(s) || !oneLine(s.label, 80) || !oneLine(s.ref, 500)) return { ok: false, error: 'each source needs a label (up to 80 characters) and a ref (up to 500)' };
|
|
113
|
+
sources.push({ label: s.label.trim(), ref: s.ref.trim() });
|
|
114
|
+
}
|
|
115
|
+
if (origin === 'web' && !sources.some((s) => isUrl(s.ref))) return { ok: false, error: 'a web finding needs at least one source that is a public page address (https://…)' };
|
|
116
|
+
if (x.match != null && x.match !== '' && !(typeof x.match === 'string' && ID_RE.test(x.match))) return { ok: false, error: 'match is an initiative id' };
|
|
117
|
+
return { ok: true, finding: { origin, title: x.title.trim(), why: x.why.trim(), sources, match: x.match ? x.match : null } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── the duplicate test ───────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'for', 'from', 'get', 'has', 'have', 'how', 'in', 'into', 'is', 'it', 'its', 'of', 'on', 'or', 'our', 'so', 'that', 'the',
|
|
122
|
+
'their', 'them', 'they', 'this', 'to', 'up', 'us', 'we', 'what', 'when', 'with', 'you', 'your', 'before', 'after', 'than', 'then', 'new', 'make', 'give', 'show', 'let', 'use', 'using']);
|
|
123
|
+
/** The significant words of a title: lower case, no stop words, a light plural stem. */
|
|
124
|
+
function words(title) {
|
|
125
|
+
const out = new Set();
|
|
126
|
+
for (const raw of String(title || '').toLowerCase().split(/[^a-z0-9]+/)) {
|
|
127
|
+
if (raw.length < 3 && !/\d/.test(raw)) continue;
|
|
128
|
+
if (STOP.has(raw)) continue;
|
|
129
|
+
let w = raw;
|
|
130
|
+
if (w.length > 4 && w.endsWith('ies')) w = w.slice(0, -3) + 'y';
|
|
131
|
+
else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) w = w.slice(0, -1);
|
|
132
|
+
if (!STOP.has(w)) out.add(w);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** The same page by another spelling: no scheme, no www, no fragment, no tracking query, no trailing slash. */
|
|
137
|
+
function normUrl(u) {
|
|
138
|
+
try {
|
|
139
|
+
const x = new URL(String(u).trim());
|
|
140
|
+
const q = [...x.searchParams.entries()].filter(([k]) => !/^(utm_|fbclid$|gclid$|ref$)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
|
|
141
|
+
const query = q.length ? '?' + q.map(([k, v]) => `${k}=${v}`).join('&') : '';
|
|
142
|
+
return (x.hostname.toLowerCase().replace(/^www\./, '') + x.pathname.replace(/\/+$/, '') + query).toLowerCase();
|
|
143
|
+
} catch { return String(u || '').trim().toLowerCase(); }
|
|
144
|
+
}
|
|
145
|
+
/** Two titles with the same significant words: equal sets, or (from three words up) four in five shared. */
|
|
146
|
+
function sameWords(a, b) {
|
|
147
|
+
const wa = words(a); const wb = words(b);
|
|
148
|
+
if (!wa.size || !wb.size) return false;
|
|
149
|
+
let both = 0;
|
|
150
|
+
for (const w of wa) if (wb.has(w)) both++;
|
|
151
|
+
if (both === wa.size && both === wb.size) return true;
|
|
152
|
+
if (Math.min(wa.size, wb.size) < 3) return false;
|
|
153
|
+
return both / (wa.size + wb.size - both) >= 0.75;
|
|
154
|
+
}
|
|
155
|
+
/** The earlier finding on the same board that this one repeats (the same public page, or the same significant title words), or null. */
|
|
156
|
+
function duplicateOf(state, board, { title, sources } = {}) {
|
|
157
|
+
const urls = new Set((sources || []).filter((s) => s && isUrl(s.ref)).map((s) => normUrl(s.ref)));
|
|
158
|
+
for (const id of state.findingOrder) {
|
|
159
|
+
const f = state.findings.get(id);
|
|
160
|
+
if (!f || f.board !== board) continue;
|
|
161
|
+
if (urls.size && (f.sources || []).some((s) => isUrl(s.ref) && urls.has(normUrl(s.ref)))) return f;
|
|
162
|
+
if (sameWords(f.title, title)) return f;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
function boardState(s, id) {
|
|
169
|
+
if (!s.boards.has(id)) s.boards.set(id, { id, cadence: DEFAULT_CADENCE, cadenceAt: null, items: new Map(), order: [] });
|
|
170
|
+
return s.boards.get(id);
|
|
171
|
+
}
|
|
172
|
+
const emptyState = () => ({ boards: new Map(), scans: new Map(), scanOrder: [], findings: new Map(), findingOrder: [] });
|
|
173
|
+
|
|
174
|
+
/** The current state of one ledger. A decision line from anything but the PM is ignored; the first line for an id wins. */
|
|
175
|
+
function fold(lines) {
|
|
176
|
+
const s = emptyState();
|
|
177
|
+
for (const l of lines || []) {
|
|
178
|
+
if (!isObj(l) || typeof l.board !== 'string' || !ID_RE.test(l.board)) continue;
|
|
179
|
+
const b = boardState(s, l.board);
|
|
180
|
+
switch (l.ev) {
|
|
181
|
+
case 'watch-suggest': {
|
|
182
|
+
if (!WATCH_RE.test(String(l.id)) || b.items.has(l.id) || !KINDS.includes(l.kind) || !oneLine(l.name, 80)) break;
|
|
183
|
+
b.items.set(l.id, { id: l.id, kind: l.kind, name: l.name, why: typeof l.why === 'string' ? l.why : '', ini: typeof l.ini === 'string' ? l.ini : null, added: false, state: 'suggested', prev: null, at: l.at || null });
|
|
184
|
+
b.order.push(l.id);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'watch-add': {
|
|
188
|
+
if (!pm(l.by) || !WATCH_RE.test(String(l.id)) || b.items.has(l.id) || !KINDS.includes(l.kind) || !oneLine(l.name, 80)) break;
|
|
189
|
+
b.items.set(l.id, { id: l.id, kind: l.kind, name: l.name, why: typeof l.why === 'string' ? l.why : '', ini: null, added: true, state: 'kept', prev: null, at: l.at || null });
|
|
190
|
+
b.order.push(l.id);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'watch-keep': {
|
|
194
|
+
const it = b.items.get(l.id);
|
|
195
|
+
if (!it || !pm(l.by)) break;
|
|
196
|
+
if (it.state === 'suggested') it.state = 'kept';
|
|
197
|
+
else if (it.state === 'removed' && l.restore === true) { it.state = it.prev || 'kept'; it.prev = null; }
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'watch-edit': {
|
|
201
|
+
const it = b.items.get(l.id);
|
|
202
|
+
if (!it || !pm(l.by) || it.state === 'removed') break;
|
|
203
|
+
if (oneLine(l.name, 80)) it.name = l.name.trim();
|
|
204
|
+
if (KINDS.includes(l.kind)) it.kind = l.kind;
|
|
205
|
+
if (typeof l.why === 'string') it.why = l.why.trim();
|
|
206
|
+
it.state = 'kept';
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'watch-remove': {
|
|
210
|
+
const it = b.items.get(l.id);
|
|
211
|
+
if (!it || !pm(l.by) || it.state === 'removed') break;
|
|
212
|
+
it.prev = it.state; it.state = 'removed';
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case 'cadence': if (pm(l.by) && CADENCES.includes(l.cadence)) { b.cadence = l.cadence; b.cadenceAt = l.at || null; } break;
|
|
216
|
+
case 'scan-start': {
|
|
217
|
+
if (!SCAN_RE.test(String(l.scan)) || s.scans.has(l.scan) || !MODES.includes(l.mode)) break;
|
|
218
|
+
s.scans.set(l.scan, { scan: l.scan, board: l.board, mode: l.mode, start: l.at || null, end: null, ok: null, reason: null, watch: Array.isArray(l.watch) ? l.watch : [],
|
|
219
|
+
findings: [], skips: 0, pagesSaid: 0, pages: 0, found: 0, skipped: 0, runs: 0, runLines: [] });
|
|
220
|
+
s.scanOrder.push(l.scan);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'finding': {
|
|
224
|
+
const sc = s.scans.get(l.scan);
|
|
225
|
+
if (!sc || !FINDING_RE.test(String(l.id))) break;
|
|
226
|
+
const had = s.findings.get(l.id);
|
|
227
|
+
if (had) { if (typeof l.match === 'string' && ID_RE.test(l.match) && !l.title) had.match = l.match; break; }
|
|
228
|
+
if (!ORIGINS.includes(l.origin) || !oneLine(l.title, 160)) break;
|
|
229
|
+
const f = { id: l.id, board: l.board, scan: l.scan, origin: l.origin, title: l.title, why: typeof l.why === 'string' ? l.why : '', sources: Array.isArray(l.sources) ? l.sources : [],
|
|
230
|
+
match: typeof l.match === 'string' && ID_RE.test(l.match) ? l.match : null, by: l.by || null, run: RUN_RE.test(String(l.run)) ? l.run : null, at: l.at || null };
|
|
231
|
+
s.findings.set(l.id, f); s.findingOrder.push(l.id); sc.findings.push(l.id);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case 'skip': { const sc = s.scans.get(l.scan); if (sc) sc.skips++; break; }
|
|
235
|
+
case 'pages': { const sc = s.scans.get(l.scan); if (sc && Number.isFinite(l.pages) && l.pages >= 0) sc.pagesSaid = Math.max(sc.pagesSaid, Math.floor(l.pages)); break; }
|
|
236
|
+
case 'run-end': { const sc = s.scans.get(l.scan); if (sc && RUN_RE.test(String(l.run))) sc.runLines.push({ run: l.run, origin: l.origin, ok: l.ok === true, reason: typeof l.reason === 'string' ? l.reason : null, agent_run: isObj(l.agent_run) ? l.agent_run : null, at: l.at || null }); break; }
|
|
237
|
+
case 'scan-end': {
|
|
238
|
+
const sc = s.scans.get(l.scan);
|
|
239
|
+
if (!sc || sc.end) break;
|
|
240
|
+
sc.end = l.at || null; sc.ok = l.ok === true; sc.reason = typeof l.reason === 'string' ? l.reason : null;
|
|
241
|
+
sc.pages = Number.isFinite(l.pages) ? l.pages : sc.pagesSaid; sc.found = Number.isFinite(l.found) ? l.found : sc.findings.length;
|
|
242
|
+
sc.skipped = Number.isFinite(l.skipped) ? l.skipped : sc.skips; sc.runs = Number.isFinite(l.runs) ? l.runs : sc.runLines.length;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
default: break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// The page count of a scan still open, and of one whose end line has none: what the agent said, else the public pages its findings cite.
|
|
249
|
+
for (const sc of s.scans.values()) {
|
|
250
|
+
const cited = new Set();
|
|
251
|
+
for (const id of sc.findings) for (const x of s.findings.get(id).sources) if (isUrl(x.ref)) cited.add(normUrl(x.ref));
|
|
252
|
+
if (!sc.end) sc.pages = Math.max(sc.pagesSaid, cited.size);
|
|
253
|
+
}
|
|
254
|
+
return s;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const cadenceOf = (state, board) => (state.boards.get(board) ? state.boards.get(board).cadence : DEFAULT_CADENCE);
|
|
258
|
+
/** The items of a board's watch list that are not removed: { suggested: [], kept: [] }. */
|
|
259
|
+
function watchOf(state, board) {
|
|
260
|
+
const b = state.boards.get(board);
|
|
261
|
+
const out = { suggested: [], kept: [] };
|
|
262
|
+
if (!b) return out;
|
|
263
|
+
for (const id of b.order) { const it = b.items.get(id); if (it.state === 'suggested') out.suggested.push(it); else if (it.state === 'kept') out.kept.push(it); }
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
/** The same items in the order they were filed, each with its state: [{ …item, state: 'suggested'|'kept' }]. */
|
|
267
|
+
function itemsOf(state, board) {
|
|
268
|
+
const b = state.boards.get(board);
|
|
269
|
+
return b ? b.order.map((id) => b.items.get(id)).filter((it) => it.state !== 'removed') : [];
|
|
270
|
+
}
|
|
271
|
+
const keptItems = (state, board) => watchOf(state, board).kept;
|
|
272
|
+
const hasWatchList = (state, board) => { const b = state.boards.get(board); return !!(b && b.order.length); };
|
|
273
|
+
const scansOf = (state, board, mode) => state.scanOrder.map((id) => state.scans.get(id)).filter((x) => x.board === board && (!mode || x.mode === mode));
|
|
274
|
+
const openScans = (state) => state.scanOrder.map((id) => state.scans.get(id)).filter((x) => !x.end);
|
|
275
|
+
/** The scans of a board that started on a local day (YYYY-MM-DD). A scan that failed still counts: one scan per board per day. */
|
|
276
|
+
const scanDay = (state, board, day) => scansOf(state, board, 'scan').filter((x) => x.start && D.localDay(Date.parse(x.start)) === day);
|
|
277
|
+
const lastScan = (state, board) => scansOf(state, board, 'scan').filter((x) => x.end).pop() || null;
|
|
278
|
+
|
|
279
|
+
// ── the schedule ─────────────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
281
|
+
/** Daily: Monday to Friday. Weekly: Monday. Off: no day. */
|
|
282
|
+
function isWorkingDay(ms, cadence) {
|
|
283
|
+
const d = new Date(ms).getDay();
|
|
284
|
+
if (cadence === 'daily') return d >= 1 && d <= 5;
|
|
285
|
+
if (cadence === 'weekly') return d === 1;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const slotMs = (ms) => { const d = new Date(ms); const [h, m] = SLOT.split(':').map(Number); d.setHours(h, m, 0, 0); return d.getTime(); };
|
|
289
|
+
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs on this board');
|
|
290
|
+
|
|
291
|
+
/** May a scan of this board start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen).
|
|
292
|
+
* → { ok } | { ok: false, why }. A scan that missed 09:00 starts at the first tick inside the working hours of that day, never outside them. */
|
|
293
|
+
function dueNow(state, board, now, { open = true } = {}) {
|
|
294
|
+
const cadence = cadenceOf(state, board);
|
|
295
|
+
if (cadence === 'off') return { ok: false, why: 'off' };
|
|
296
|
+
if (!keptItems(state, board).length) return { ok: false, why: 'no-watch-list' };
|
|
297
|
+
if (!isWorkingDay(now, cadence)) return { ok: false, why: 'not-a-scan-day' };
|
|
298
|
+
if (now < slotMs(now)) return { ok: false, why: 'before-09' };
|
|
299
|
+
if (!open) return { ok: false, why: 'outside-working-hours' };
|
|
300
|
+
if (scanDay(state, board, D.localDay(now)).length) return { ok: false, why: 'done-today' };
|
|
301
|
+
if (openScans(state).some((x) => x.board === board)) return { ok: false, why: 'running' };
|
|
302
|
+
return { ok: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** May the one suggest run of this board start now? A board with no watch list at all, whose cadence is not off, with no successful suggest run
|
|
306
|
+
* and none today. Without a kept item nothing is scanned, so the suggestions are what the PM decides on first. */
|
|
307
|
+
function suggestDue(state, board, now, { open = true, initiatives = 0 } = {}) {
|
|
308
|
+
if (cadenceOf(state, board) === 'off') return { ok: false, why: 'off' };
|
|
309
|
+
if (hasWatchList(state, board)) return { ok: false, why: 'has-watch-list' };
|
|
310
|
+
if (!initiatives) return { ok: false, why: 'no-initiatives' };
|
|
311
|
+
if (!open) return { ok: false, why: 'outside-working-hours' };
|
|
312
|
+
const runs = scansOf(state, board, 'suggest');
|
|
313
|
+
if (runs.some((x) => !x.end)) return { ok: false, why: 'running' };
|
|
314
|
+
if (runs.some((x) => x.ok === true)) return { ok: false, why: 'asked-already' };
|
|
315
|
+
if (runs.some((x) => x.start && D.localDay(Date.parse(x.start)) === D.localDay(now))) return { ok: false, why: 'tried-today' };
|
|
316
|
+
return { ok: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** When the next scan of a board starts, in ms, or null (off, or nothing kept). `dayEnd` is the day-check end ('18:00'). A scan that is due
|
|
320
|
+
* now and has not started starts at the next tick, so the answer is `now`. */
|
|
321
|
+
function nextScan(state, board, now, { dayEnd = '18:00' } = {}) {
|
|
322
|
+
const cadence = cadenceOf(state, board);
|
|
323
|
+
if (cadence === 'off' || !keptItems(state, board).length) return null;
|
|
324
|
+
const doneToday = scanDay(state, board, D.localDay(now)).length > 0;
|
|
325
|
+
for (let k = 0; k < 8; k++) {
|
|
326
|
+
const d = new Date(now); d.setDate(d.getDate() + k);
|
|
327
|
+
const day = d.getTime();
|
|
328
|
+
if (!isWorkingDay(day, cadence)) continue;
|
|
329
|
+
if (k === 0) {
|
|
330
|
+
if (doneToday) continue;
|
|
331
|
+
if (now < slotMs(day)) return slotMs(day);
|
|
332
|
+
if (hhmm(now) < dayEnd) return now;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
return slotMs(day);
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── the PM's presses ─────────────────────────────────────────────────────────────────────────────────
|
|
341
|
+
const isPm = (actor) => !!actor && pm(actor.by);
|
|
342
|
+
function setCadence(file, board, cadence, actor, { now } = {}) {
|
|
343
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can change the scan cadence');
|
|
344
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
345
|
+
if (!CADENCES.includes(cadence)) return refuse(400, `cadence must be one of ${CADENCES.join(', ')}`);
|
|
346
|
+
if (typeof board !== 'string' || !ID_RE.test(board)) return refuse(400, 'board must be a board id');
|
|
347
|
+
append(file, { ev: 'cadence', board, cadence, by: actor.by }, { now });
|
|
348
|
+
return { ok: true, board, cadence };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** keep | edit | remove | add on a board's watch list. `input`: { id } for keep, edit and remove; { id, name?, kind?, why? } for edit; { kind, name, why? } for add. */
|
|
352
|
+
function watchOp(file, op, board, input, actor, { now } = {}) {
|
|
353
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can change the watch list');
|
|
354
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
355
|
+
if (typeof board !== 'string' || !ID_RE.test(board)) return refuse(400, 'board must be a board id');
|
|
356
|
+
if (!['keep', 'edit', 'remove', 'add'].includes(op)) return refuse(400, 'op must be keep, edit, remove or add');
|
|
357
|
+
const state = fold(readLines(file));
|
|
358
|
+
const b = state.boards.get(board);
|
|
359
|
+
const x = isObj(input) ? input : {};
|
|
360
|
+
if (op === 'add') {
|
|
361
|
+
const c = checkWatchItem(x);
|
|
362
|
+
if (!c.ok) return refuse(400, c.error);
|
|
363
|
+
if (b && [...b.items.values()].filter((i) => i.state !== 'removed').length >= LIMITS.items) return refuse(409, `a watch list holds at most ${LIMITS.items} items`);
|
|
364
|
+
if (b && [...b.items.values()].some((i) => i.state !== 'removed' && i.name.toLowerCase() === c.item.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
|
|
365
|
+
const id = newId('w');
|
|
366
|
+
append(file, { ev: 'watch-add', board, id, ...c.item, by: actor.by }, { now });
|
|
367
|
+
return { ok: true, id, item: { id, ...c.item, state: 'kept', added: true } };
|
|
368
|
+
}
|
|
369
|
+
if (typeof x.id !== 'string' || !WATCH_RE.test(x.id)) return refuse(400, 'id must be a watch list item id');
|
|
370
|
+
const it = b && b.items.get(x.id);
|
|
371
|
+
if (!it) return refuse(404, `no watch list item ${x.id.slice(0, 20)} on this board`);
|
|
372
|
+
if (op === 'keep') {
|
|
373
|
+
if (it.state === 'kept') return refuse(409, 'the item is kept already');
|
|
374
|
+
if (it.state === 'removed' && x.restore !== true) return refuse(409, 'the item was removed');
|
|
375
|
+
append(file, { ev: 'watch-keep', board, id: it.id, ...(x.restore === true ? { restore: true } : {}), by: actor.by }, { now });
|
|
376
|
+
return { ok: true, id: it.id };
|
|
377
|
+
}
|
|
378
|
+
if (it.state === 'removed') return refuse(409, 'the item was removed');
|
|
379
|
+
if (op === 'remove') { append(file, { ev: 'watch-remove', board, id: it.id, by: actor.by }, { now }); return { ok: true, id: it.id }; }
|
|
380
|
+
// edit
|
|
381
|
+
const line = { ev: 'watch-edit', board, id: it.id, by: actor.by };
|
|
382
|
+
if (x.name !== undefined) { if (!oneLine(x.name, 80)) return refuse(400, 'name is one line of 1 to 80 characters'); line.name = x.name.trim(); }
|
|
383
|
+
if (x.kind !== undefined) { if (!KINDS.includes(x.kind)) return refuse(400, `kind must be one of ${KINDS.join(', ')}`); line.kind = x.kind; }
|
|
384
|
+
if (x.why !== undefined) { if (x.why !== '' && !oneLine(x.why, 200)) return refuse(400, 'why is one line of at most 200 characters'); line.why = String(x.why).trim(); }
|
|
385
|
+
if (line.name === undefined && line.kind === undefined && line.why === undefined) return refuse(400, 'say what changes: name, kind or why');
|
|
386
|
+
if (line.name && b && [...b.items.values()].some((i) => i.id !== it.id && i.state !== 'removed' && i.name.toLowerCase() === line.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
|
|
387
|
+
append(file, line, { now });
|
|
388
|
+
return { ok: true, id: it.id };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
LEDGER, KINDS, CADENCES, DEFAULT_CADENCE, EVENTS, LIMITS, SLOT, ORIGINS, MODES, ID_RE, WATCH_RE, SCAN_RE, FINDING_RE, RUN_RE,
|
|
393
|
+
ledgerFile, append, readLines, newId, fold, cadenceOf, watchOf, itemsOf, keptItems, hasWatchList, scansOf, openScans, scanDay, lastScan,
|
|
394
|
+
checkWatchItem, checkFinding, sourceOf, isUrl, words, normUrl, sameWords, duplicateOf,
|
|
395
|
+
isWorkingDay, dueNow, suggestDue, nextScan, scheduleText, setCadence, watchOp, hhmm,
|
|
396
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.170.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.170.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -15,11 +15,12 @@ import { el } from './dom.js';
|
|
|
15
15
|
import { c3ic } from './pm-work-view.js';
|
|
16
16
|
import { pmPost } from './http.js';
|
|
17
17
|
import { makeXp, attachExpand } from './c3-expand.js';
|
|
18
|
-
import { needRow, belowLine, BX, toast } from './c3-brief.js';
|
|
18
|
+
import { needRow, belowLine, BX, toast, hooks } from './c3-brief.js';
|
|
19
19
|
import { onBriefing, loadBriefing } from './shell-ticker.js';
|
|
20
20
|
import { cardView, TAG_STYLE } from './briefing-model.js';
|
|
21
21
|
import { onBriefPage } from './c3-brief-data.js';
|
|
22
22
|
import { button, ensureComponentsCss } from './primitives.js';
|
|
23
|
+
import { loadDriverNeeds, needsRow as driverRow } from './c3-driver.js';
|
|
23
24
|
|
|
24
25
|
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
25
26
|
const post = (path, body) => pmPost(path, body).catch(() => ({ ok: false }));
|
|
@@ -104,11 +105,12 @@ export function needsSection(S) {
|
|
|
104
105
|
const sumState = { dec: 0, own: 0, late: 0, quick: 0 };
|
|
105
106
|
const own = el('ul', { class: 'list', role: 'list', 'data-needs-own': '' });
|
|
106
107
|
const ownNote = el('div');
|
|
108
|
+
const agent = el('ul', { class: 'list', role: 'list', 'data-needs-agent': '' }); // the Initiative Driver's rows (c3-driver.js)
|
|
107
109
|
const list = el('ul', { class: 'list', role: 'list' });
|
|
108
110
|
const extra = el('div');
|
|
109
111
|
const alertHost = el('div');
|
|
110
112
|
const box = el('section', { class: 'card sect', 'data-c3pg': 'needs', 'aria-label': 'Needs you' },
|
|
111
|
-
el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote, own, list, extra);
|
|
113
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote, own, agent, list, extra);
|
|
112
114
|
let sig = ''; let last = null; let ownSig = ''; let page = null;
|
|
113
115
|
const nothing = () => el('li', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing needs you right now.');
|
|
114
116
|
/* the header line: "3 decisions · 4 of yours · 2 overdue", then how many take under 2 minutes */
|
|
@@ -118,6 +120,8 @@ export function needsSection(S) {
|
|
|
118
120
|
if (!quick.isConnected) box.querySelector('.sh2').append(quick);
|
|
119
121
|
quick.replaceChildren(document.createTextNode(dec + (dec === 1 ? ' decision' : ' decisions') + ' · ' + ownRows.length + ' of yours'));
|
|
120
122
|
if (late) quick.append(' · ', el('span', { class: 'tag bad' }, late + ' overdue'));
|
|
123
|
+
const ag = agent.children.length;
|
|
124
|
+
if (ag) quick.append(' · ' + ag + ' from the agent');
|
|
121
125
|
if (sumState.quick) quick.append(' · ', c3ic('bolt', 'sm'), sumState.quick + ' under 2 min');
|
|
122
126
|
};
|
|
123
127
|
const recount = () => {
|
|
@@ -168,9 +172,21 @@ export function needsSection(S) {
|
|
|
168
172
|
extra.replaceChildren();
|
|
169
173
|
recount();
|
|
170
174
|
};
|
|
175
|
+
/* 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. */
|
|
176
|
+
let agentSig = '';
|
|
177
|
+
const goAgent = (n) => {
|
|
178
|
+
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 */ } }
|
|
179
|
+
if (hooks.go) hooks.go({ action: n.initiative_id ? 'goinit' : 'gowork' });
|
|
180
|
+
};
|
|
181
|
+
const fillAgent = () => loadDriverNeeds().then((rows) => {
|
|
182
|
+
const g = JSON.stringify(rows.map((r) => [r.run_id, r.question]));
|
|
183
|
+
if (g === agentSig) return; agentSig = g;
|
|
184
|
+
agent.replaceChildren(...rows.map((r) => driverRow(r, goAgent)));
|
|
185
|
+
recount();
|
|
186
|
+
}, () => {});
|
|
171
187
|
S.refreshNeeds = () => { sig = ''; if (last) fill(last); };
|
|
172
188
|
S.needsFill = fill;
|
|
173
|
-
onBriefing((b) => { S.brief = b; fill(b); });
|
|
189
|
+
onBriefing((b) => { S.brief = b; fill(b); fillAgent(); });
|
|
174
190
|
onBriefPage(fillOwn);
|
|
175
191
|
loadBriefing();
|
|
176
192
|
return box;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* pm/c3-driver-work.js — the Initiative Driver on a Work task (PRD section 5; mockup 39-driver.js workNote and openDraft).
|
|
2
|
+
* One agent line under the title of a task that has an open driver proposal, and the sheet behind its Review.
|
|
3
|
+
*
|
|
4
|
+
* loadDriverOpen(ws) GET /api/driver/open?target_kind=work → { ok, ws, mode, targets: { <task id>: [proposal] } }, or null
|
|
5
|
+
* workNote(o) the line: o = { item, props, ws, mode, done } → a node, or null (Shadow and Off draw no line, PRD section 5)
|
|
6
|
+
* openDraftSheet(o) the sheet: the draft text, the line that the agent sent nothing, Keep (copies the text), Send back, Drop
|
|
7
|
+
*
|
|
8
|
+
* A kept follow-up is copied to the clipboard and recorded. When the browser refuses the clipboard, the text is selected and the
|
|
9
|
+
* sheet stays open so Ctrl+C works. Presses: POST /api/driver/keep, return, drop (the server takes the PM from the call path).
|
|
10
|
+
* `done()` runs after a press so the Work list reads again. Nothing here sends anything to a person.
|
|
11
|
+
*/
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { SEL } from './primitives.js';
|
|
14
|
+
import { pmGet, pmPost } from './http.js';
|
|
15
|
+
import { c3ic } from './pm-work-view.js';
|
|
16
|
+
import { openSheet } from './c3-sheet.js';
|
|
17
|
+
import { toast } from './c3-toast.js';
|
|
18
|
+
import { pbtn, chip, tag, put } from './c3-ini-ui.js';
|
|
19
|
+
import { whenLabel, dueLabel, titleOf, ensureDriverCss } from './c3-driver.js';
|
|
20
|
+
|
|
21
|
+
export async function loadDriverOpen(ws) {
|
|
22
|
+
const r = await pmGet('/api/driver/open?target_kind=work' + (ws ? '&ws=' + encodeURIComponent(ws) : ''));
|
|
23
|
+
return r && r.ok ? r : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const press = async (ws, route, body, what) => {
|
|
27
|
+
const r = await pmPost('/api/driver/' + route, { ws, ...body });
|
|
28
|
+
if (!r || !r.ok) { toast({ text: what + ' did not work: ' + ((r && r.error) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 }); return null; }
|
|
29
|
+
return r;
|
|
30
|
+
};
|
|
31
|
+
const short = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
32
|
+
const lead = (props) => props.find((p) => p.what === 'follow-up') || props[0];
|
|
33
|
+
const who = (item, p) => (p.data && typeof p.data.to === 'string' && p.data.to) || item.waiting_on || '';
|
|
34
|
+
|
|
35
|
+
/** The words of the line, by what the agent filed. Pure. */
|
|
36
|
+
export function lineOf(item, props) {
|
|
37
|
+
const p = lead(props || []);
|
|
38
|
+
if (!p) return null;
|
|
39
|
+
const more = props.length > 1 ? ' (+' + (props.length - 1) + ' more)' : '';
|
|
40
|
+
const t = p.data && typeof p.data.text === 'string' ? p.data.text : '';
|
|
41
|
+
if (p.what === 'follow-up') return { p, text: 'Agent drafted a follow-up' + (who(item, p) ? ' to ' + who(item, p) : '') + '. Nothing is sent.' + more, act: 'review' };
|
|
42
|
+
if (p.what === 'next-step') return { p, text: 'Agent suggests ' + (p.data && p.data.due && !t ? 'a new due date: ' + dueLabel(p.data.due) : 'a next step: ' + short(t, 70) + (p.data && p.data.due ? ', due ' + dueLabel(p.data.due) : '')) + '.' + more, act: 'keepdrop' };
|
|
43
|
+
return { p, text: 'Agent left a note: ' + short(t, 70) + more, act: 'review' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function workNote({ item, props, ws, mode, done }) {
|
|
47
|
+
if (mode !== 'approve') return null;
|
|
48
|
+
ensureDriverCss();
|
|
49
|
+
const l = lineOf(item, props);
|
|
50
|
+
if (!l) return null;
|
|
51
|
+
const lk = (label, attrs, fn) => pbtn('ghost', label, null, { ...attrs, class: 'lk', onclick: (e) => { e.stopPropagation(); fn(); } });
|
|
52
|
+
const decide = async (route) => {
|
|
53
|
+
const r = await press(ws, route, { proposal: l.p.id }, route === 'keep' ? 'Keep' : 'Drop');
|
|
54
|
+
if (!r) return;
|
|
55
|
+
toast(route === 'keep' ? 'Kept: ' + titleOf(l.p) + '.' : 'Dropped the suggestion.');
|
|
56
|
+
done();
|
|
57
|
+
};
|
|
58
|
+
return el('span', { class: 'drv-note', 'data-drv-wnote': item.id, 'data-drv-what': l.p.what }, c3ic('spark', 'sm'), l.text, ' ',
|
|
59
|
+
l.act === 'review' ? lk('Review', { 'data-drv-wnote-act': 'review' }, () => openDraftSheet({ item, props, ws, done }))
|
|
60
|
+
: [lk('Keep', { 'data-drv-wnote-act': 'keep' }, () => decide('keep')), lk('Drop', { 'data-drv-wnote-act': 'drop' }, () => decide('drop'))]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Copy `text`. The clipboard first; when the browser refuses, select the node's text so Ctrl+C works. Returns 'copied' or 'selected'. */
|
|
64
|
+
export async function copyText(text, node) {
|
|
65
|
+
try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return 'copied'; } } catch { /* fall back to the selection */ }
|
|
66
|
+
try { const r = document.createRange(); r.selectNodeContents(node); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { /* the PM selects by hand */ }
|
|
67
|
+
return 'selected';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function openDraftSheet({ item, props, ws, done }) {
|
|
71
|
+
ensureDriverCss();
|
|
72
|
+
const p = lead(props);
|
|
73
|
+
const text = (p.data && typeof p.data.text === 'string' && p.data.text) || '';
|
|
74
|
+
const isDraft = p.what === 'follow-up';
|
|
75
|
+
const to = who(item, p);
|
|
76
|
+
const draft = el('p', { class: 'sit drv-pre', 'data-drv': 'draft' }, text || 'The agent filed no text.');
|
|
77
|
+
const backBox = el('div', { class: 'form', hidden: true, 'data-drv': 'back-box' }, el('input', { type: 'text', placeholder: 'Tell the agent what to change', 'aria-label': 'Note to the agent', 'data-drv': 'note', maxlength: '500', autocomplete: 'off' }));
|
|
78
|
+
const status = el('div', { class: 'fold', 'data-drv': 'sheet-note' }, c3ic('info', 'sm'), isDraft ? 'The agent sent nothing and cannot send. Keep copies this text so you can send it yourself.' : 'The agent sent nothing. Keep records that you rely on this note.');
|
|
79
|
+
const sayStatus = (t, warn) => { status.className = 'fold' + (warn ? ' w' : ''); put(status, c3ic(warn ? 'alert' : 'info', 'sm'), t); };
|
|
80
|
+
let sh = null; let backOpen = false;
|
|
81
|
+
const keepBtn = pbtn('primary', isDraft ? 'Keep (copy to send yourself)' : 'Keep', 'check', { 'data-drv': 'sheet-keep', onclick: async () => {
|
|
82
|
+
const r = await press(ws, 'keep', { proposal: p.id }, 'Keep');
|
|
83
|
+
if (!r) return;
|
|
84
|
+
const how = isDraft && text ? await copyText(text, draft) : 'none';
|
|
85
|
+
done();
|
|
86
|
+
if (how === 'selected') { sayStatus('Kept. The browser did not allow copying, so the text is selected: press Ctrl+C. Nothing was sent.', true); keepBtn.disabled = true; return; }
|
|
87
|
+
sh.close();
|
|
88
|
+
toast(how === 'copied' ? 'Draft copied. Nothing was sent: send it yourself.' : 'Kept.');
|
|
89
|
+
} });
|
|
90
|
+
const backBtn = pbtn('outline', 'Send back', 'reply', { 'data-drv': 'sheet-back', onclick: async () => {
|
|
91
|
+
const inp = backBox.querySelector('input');
|
|
92
|
+
if (!backOpen) { backOpen = true; backBox.hidden = false; inp.focus(); backBtn.querySelector(SEL.btnLabel).textContent = 'Send back with note'; return; }
|
|
93
|
+
const n = inp.value.trim();
|
|
94
|
+
if (!n) { sayStatus('Write one line for the agent. A note starts its next run.', true); inp.focus(); return; }
|
|
95
|
+
const r = await press(ws, 'return', { proposal: p.id, note: n }, 'Send back');
|
|
96
|
+
if (!r) return;
|
|
97
|
+
sh.close(); toast('Sent back with your note. The agent redrafts it.'); done();
|
|
98
|
+
} });
|
|
99
|
+
const dropBtn = pbtn('ghost', 'Drop', null, { 'data-drv': 'sheet-drop', onclick: async () => {
|
|
100
|
+
const r = await press(ws, 'drop', { proposal: p.id }, 'Drop');
|
|
101
|
+
if (!r) return;
|
|
102
|
+
sh.close(); toast('Dropped the draft.'); done();
|
|
103
|
+
} });
|
|
104
|
+
sh = openSheet({ title: isDraft ? 'Draft follow-up' + (to ? ' to ' + to : '') : titleOf(p), icon: 'mail', body: el('div', { class: 'drv-sheet', 'data-drv': 'work-sheet' },
|
|
105
|
+
el('div', { class: 'chips' }, tag(isDraft ? 'draft' : p.what), to ? chip(c3ic('user', 'sm'), to) : null, p.at ? chip(c3ic('clock', 'sm'), whenLabel(p.at)) : null, chip(c3ic('check', 'sm'), short(item.title, 48))),
|
|
106
|
+
el('div', { class: 'case' }, draft),
|
|
107
|
+
p.why ? el('div', { class: 'dim drv-small' }, 'Why: ' + p.why) : null, status, backBox,
|
|
108
|
+
el('div', { class: 'iacts chips2' }, keepBtn, backBtn, dropBtn)) });
|
|
109
|
+
return sh;
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* pm/c3-driver.css — the few rules the Initiative Driver needs beyond console3.css (c3-driver.js, c3-driver-work.js). The mockup's
|
|
2
|
+
* 39-driver.css under the `drv-` prefix: the Agent strip on an initiative's Overview, the Agent board setting, the agent line on a Work
|
|
3
|
+
* row and the sheets. Held to the R28 rules: tokens only, no inline style, corners 0, 6px or 8px, font sizes from the --fs tokens. */
|
|
4
|
+
.c3 .drv .nrow .acts{flex-wrap:wrap;justify-content:flex-end}
|
|
5
|
+
.c3 .drv-src{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
|
6
|
+
.c3 .drv-src .drv-ref{font-size:var(--fs-1);color:var(--muted-foreground);overflow-wrap:anywhere}
|
|
7
|
+
.c3 .drv-src a.drv-ref{color:var(--acc)}
|
|
8
|
+
.c3 .drv-pre{margin:0;white-space:pre-line;overflow-wrap:anywhere}
|
|
9
|
+
.c3 .drv-small{font-size:var(--fs-2);line-height:1.45}
|
|
10
|
+
.c3 .drv-sub{margin-top:2px}
|
|
11
|
+
.c3 .drv-set{display:grid;gap:var(--sp-2)}
|
|
12
|
+
.c3 .drv-set .seg{max-width:100%;overflow-x:auto}
|
|
13
|
+
.c3 .drv-note{display:block}
|
|
14
|
+
.c3 .drv-note .ic{vertical-align:-2px;margin-right:6px}
|
|
15
|
+
.c3 .drv-note .lk{margin-left:4px}
|
|
16
|
+
.c3 .drv-sheet{display:grid;gap:var(--sp-3)}
|
|
17
|
+
.c3 .drv-sheet .sit{white-space:pre-line;overflow-wrap:anywhere}
|
|
18
|
+
.c3 .drv-sheet input[type=text]{width:100%}
|