create-pmos 0.522.0 → 0.524.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/bin/create-pmos.js +26 -1
- package/package.json +57 -47
- 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/changelog.json +56 -0
- package/workspace/guide/agent-capabilities.md +6 -3
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/horizon-scan.md +2 -2
- package/workspace/guide/idle-lab.md +46 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/moments.md +43 -0
- package/workspace/guide/welcome.md +37 -28
- package/workspace/lib/bg-queue.js +226 -0
- package/workspace/lib/connector-read.js +19 -0
- package/workspace/lib/driver-run.js +8 -1
- package/workspace/lib/first-reveal.js +311 -0
- package/workspace/lib/headless.js +2 -2
- package/workspace/lib/idle-lab.js +275 -0
- package/workspace/lib/moments.js +241 -0
- package/workspace/lib/morning-goals.js +191 -0
- package/workspace/lib/onboard-fundamentals.js +205 -0
- package/workspace/lib/onboarding-progress.js +13 -1
- package/workspace/lib/orchestrator-spawn.js +9 -2
- package/workspace/lib/project-onboard.js +93 -22
- package/workspace/lib/proposals.js +9 -5
- package/workspace/lib/refresh-run.js +2 -1
- package/workspace/lib/route-modules.js +4 -0
- package/workspace/lib/routes-lab.js +137 -0
- package/workspace/lib/routes-loose.js +1 -0
- package/workspace/lib/routes-moments.js +42 -0
- package/workspace/lib/routes-morning-goals.js +109 -0
- package/workspace/lib/routes-onboard.js +45 -8
- package/workspace/lib/routes-proposals.js +12 -2
- package/workspace/lib/routes-reveal.js +73 -0
- package/workspace/lib/scan-run.js +4 -3
- package/workspace/lib/scan.js +5 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/project-onboard-flow-test.js +70 -57
- package/workspace/public/pm/cozy-ev.css +3 -0
- package/workspace/public/pm/cozy-lab-ins.js +87 -0
- package/workspace/public/pm/cozy-lab.css +86 -0
- package/workspace/public/pm/cozy-lab.js +138 -0
- package/workspace/public/pm/cozy-nav.js +22 -8
- package/workspace/public/pm/cozy-onboard.css +31 -0
- package/workspace/public/pm/cozy-onboard.js +217 -111
- package/workspace/public/pm/cozy-reveal.css +52 -0
- package/workspace/public/pm/cozy-reveal.js +130 -0
- package/workspace/public/pm/home-app.js +38 -3
- package/workspace/public/pm/home-impact.css +12 -0
- package/workspace/public/pm/home-moment.js +41 -0
- package/workspace/public/pm/ini-props.js +15 -6
- package/workspace/public/pm/premium-board.js +2 -0
- package/workspace/public/pm/premium-first.js +1 -1
- package/workspace/public/pm/premium-insights.js +6 -1
- package/workspace/public/pm/quiet-place.js +31 -0
- package/workspace/public/premium-intelligence.html +3 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +46 -5
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/first-reveal.json +9 -0
- package/workspace/test-lists/idle-lab.json +9 -0
- package/workspace/test-lists/morning-goals.json +9 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* bg-queue.js — ONE queue for all of Claude's background work (idle lab design, frame I4 "One queue for all background work",
|
|
3
|
+
* agreed by the PM 2026-11-04). Every background job asks this queue for its turn, once a minute, from the server's one tick.
|
|
4
|
+
*
|
|
5
|
+
* JOBS · STEPS · CAP the job table, the five steps in priority order, the runs-at-once limit
|
|
6
|
+
* plan({ jobs, now, idleMs, quiet, running, offOf, spend, workEnd? })
|
|
7
|
+
* PURE → [{ id, step, go, why, idle }] in the order the jobs take their turn
|
|
8
|
+
* holderOf(sessions, initiative, { except? }) PURE → the live background session that holds the initiative, or null
|
|
9
|
+
* jobOfOrigin(origin) PURE → the queue job a background session's origin belongs to, or null
|
|
10
|
+
* create(host) → { register(id, fn), tick(now?), status(), holder(initiative, except?), idle(now?), ended(session, now?), record({ ws?, since? }) }
|
|
11
|
+
*
|
|
12
|
+
* THE ORDER (each tick, step by step; inside a step, in the order of JOBS):
|
|
13
|
+
* 1 asked what the PM asked for: Scan now, "grill this", Prepare my day. A press starts at once through the route and is never
|
|
14
|
+
* held, so step 1 has no tick here. It is in the table so the Lab can list it.
|
|
15
|
+
* 2 due what is due at a set time: schedules, the morning brief and the day check, the day plan, the data refresh, report
|
|
16
|
+
* schedules, Git sync
|
|
17
|
+
* 3 driver the Initiative Driver
|
|
18
|
+
* 4 deep the deep drafts: core documents, Your impact, metric drafts and reads, proposals, grill, the report finder, the
|
|
19
|
+
* report design and structure checks, milestone reviews, the forecast
|
|
20
|
+
* 5 idle the idle lab and the slow reads: the Horizon scan (its daily run waits for the first idle stretch of the day)
|
|
21
|
+
*
|
|
22
|
+
* THE CHECKS (made once per tick, for every job):
|
|
23
|
+
* off switch lib/ai-guard.js off(job): PMOS_AI=off, PMOS_AI_<JOB>=off, Settings ai.on / ai.off (`pmos ai off [job]`). A job with
|
|
24
|
+
* `ai: false` (Git sync: no model) is not an AI job, so the switch does not stop it.
|
|
25
|
+
* idle clock lib/pm-idle.js idleFor (30 minutes with no press and no key). Step 5 only. In the last hour of the working day a step-5
|
|
26
|
+
* job runs even when the PM never stepped away, so a once-a-day job still runs once a day.
|
|
27
|
+
* hours quiet hours hold step 5. Steps 2 to 4 keep the hours rules they have today (each job's own window).
|
|
28
|
+
* spend Settings › Claude's checks spend limit (grill.cap, the month's spend from grill-runs.jsonl). At the limit step 5 holds.
|
|
29
|
+
* Steps 2 to 4 are not held: grill holds its own runs at the cap, and the rest run on the PM's plan, not on credits.
|
|
30
|
+
* capacity CAP (3) background runs at once (lib/orchestrator-spawn.js running(): the Driver's runs, the scheduled runs and the
|
|
31
|
+
* PM's working chat sessions). At the limit step 5 holds. Steps 2 to 4 start their runs through orchestrator-spawn,
|
|
32
|
+
* which holds each start with the capacity governor (lib/capacity-governor.js) as it does today.
|
|
33
|
+
* one per initiative holderOf(): a background run never starts on an initiative another background run holds. orchestrator-spawn
|
|
34
|
+
* and the Driver ask holder() before each start; the held start is tried again on the next tick.
|
|
35
|
+
* A job that is not held runs its own tick, which keeps its own due rules (once a day, its time window, its inputs). Nothing a job
|
|
36
|
+
* shows changes: the queue decides only WHEN a job may look, never what it files.
|
|
37
|
+
*
|
|
38
|
+
* THE RECORD, <stateDir>/bg-queue.jsonl:
|
|
39
|
+
* turn { at, kind: 'turn', job, step, go, why } a job's turn changed (it was held and now goes, or the reason changed). A steady
|
|
40
|
+
* minute writes nothing.
|
|
41
|
+
* done { at, kind: 'done', job, title, ws, ini?, session, meta? } a background session ended (ended(), from the server's one
|
|
42
|
+
* session-end hook). A session the PM started, or a chat, is not written.
|
|
43
|
+
* status() is the last plan, for the Lab ("what runs next"). record({ ws, since }) is the done lines, newest first, for the Lab's
|
|
44
|
+
* "What Claude did in the background" (lib/routes-lab.js): [{ job, title, meta?, at, ini?, board? }].
|
|
45
|
+
* PMOS_QUEUE_IDLE_MS (milliseconds) fixes the idle clock the queue reads, for the suites.
|
|
46
|
+
*/
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
|
|
50
|
+
const CAP = 3;
|
|
51
|
+
const STEPS = Object.freeze([
|
|
52
|
+
{ step: 1, id: 'asked', label: 'What you asked for' },
|
|
53
|
+
{ step: 2, id: 'due', label: 'Due at a set time' },
|
|
54
|
+
{ step: 3, id: 'driver', label: 'The Driver' },
|
|
55
|
+
{ step: 4, id: 'deep', label: 'Deep drafts' },
|
|
56
|
+
{ step: 5, id: 'idle', label: 'Idle lab and slow reads' },
|
|
57
|
+
]);
|
|
58
|
+
/* id: the queue's job id (also the `pmos ai off <job>` id). ai: false = no model, so the AI off switch does not stop it. */
|
|
59
|
+
const JOBS = Object.freeze([
|
|
60
|
+
{ id: 'schedules', step: 2, label: 'Scheduled skills' },
|
|
61
|
+
{ id: 'proactive', step: 2, label: 'Morning brief and day check' },
|
|
62
|
+
{ id: 'prepare', step: 2, label: 'Day tasks' },
|
|
63
|
+
{ id: 'day', step: 2, label: 'Day plan' },
|
|
64
|
+
{ id: 'refresh', step: 2, label: 'Data refresh' },
|
|
65
|
+
{ id: 'reports', step: 2, label: 'Report schedules' },
|
|
66
|
+
{ id: 'sync', step: 2, label: 'Git sync', ai: false },
|
|
67
|
+
{ id: 'driver', step: 3, label: 'Initiative Driver' },
|
|
68
|
+
{ id: 'finder', step: 4, label: 'Report finder' },
|
|
69
|
+
{ id: 'coredocs', step: 4, label: 'Core documents' },
|
|
70
|
+
{ id: 'milestones', step: 4, label: 'Milestone reviews' },
|
|
71
|
+
{ id: 'propose', step: 4, label: 'Found elsewhere and task checks' },
|
|
72
|
+
{ id: 'grill', step: 4, label: 'Grill' },
|
|
73
|
+
{ id: 'metric-drafts', step: 4, label: 'Metric drafts' },
|
|
74
|
+
{ id: 'impact', step: 4, label: 'Your impact' },
|
|
75
|
+
{ id: 'metric-reads', step: 4, label: 'Metric reads' },
|
|
76
|
+
{ id: 'forecast', step: 4, label: 'Forecast' },
|
|
77
|
+
{ id: 'report-design', step: 4, label: 'Report design' },
|
|
78
|
+
{ id: 'report-structure', step: 4, label: 'Report structure' },
|
|
79
|
+
{ id: 'scan', step: 5, label: 'Horizon scan' },
|
|
80
|
+
]);
|
|
81
|
+
const BY_ID = new Map(JOBS.map((j) => [j.id, j]));
|
|
82
|
+
const LAST_HOUR_MS = 60 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
const hhmmMs = (now, hhmm) => {
|
|
85
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || ''));
|
|
86
|
+
if (!m) return null;
|
|
87
|
+
const d = new Date(now); d.setHours(+m[1], +m[2], 0, 0); return d.getTime();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** PURE. The turn of every job this tick. `offOf(id)` → null | reason; spend: { atCap, why? } | null; workEnd: '18:00' (the day-check
|
|
91
|
+
* end), for the last-hour rule. Each row's idle is true when the PM has been away idleLimit or more, or in the last hour of the day. */
|
|
92
|
+
function plan({ jobs = JOBS, now = Date.now(), idleMs = 0, idleLimit = 30 * 60 * 1000, quiet = false, running = 0, cap = CAP, offOf = () => null, spend = null, workEnd = null } = {}) {
|
|
93
|
+
const idle = idleMs >= idleLimit;
|
|
94
|
+
const end = workEnd ? hhmmMs(now, workEnd) : null;
|
|
95
|
+
const lastHour = end !== null && now < end && end - now <= LAST_HOUR_MS;
|
|
96
|
+
const away = idle || lastHour; // the turn's idle flag: the PM stepped away, or the day's last hour came first
|
|
97
|
+
const rows = [...jobs].sort((a, b) => a.step - b.step);
|
|
98
|
+
let live = Math.max(0, Number(running) || 0);
|
|
99
|
+
return rows.map((j) => {
|
|
100
|
+
const row = (go, why) => ({ id: j.id, step: j.step, go, why: go ? null : why, idle: away });
|
|
101
|
+
if (j.ai !== false) { const o = offOf(j.id); if (o) return row(false, o); }
|
|
102
|
+
if (j.step < 5) return row(true);
|
|
103
|
+
if (quiet) return row(false, 'quiet hours');
|
|
104
|
+
if (!away) return row(false, 'waiting for you to step away');
|
|
105
|
+
if (spend && spend.atCap) return row(false, spend.why || "the spend limit in Settings › Claude's checks is reached");
|
|
106
|
+
if (live >= cap) return row(false, `${live} background runs are live (limit ${cap})`);
|
|
107
|
+
live++; // a step-5 job may start one run: the next one sees it
|
|
108
|
+
return row(true);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** PURE. The live background session that holds `initiative`. sessions: [{ id, initiative?, origin?, state, background? }]. A session
|
|
113
|
+
* the PM started by hand (no origin) never holds an initiative here. `except`: a session id or an origin prefix ('drive:') to leave out. */
|
|
114
|
+
function holderOf(sessions, initiative, { except = null } = {}) {
|
|
115
|
+
const ini = String(initiative || '');
|
|
116
|
+
if (!ini) return null;
|
|
117
|
+
for (const s of sessions || []) {
|
|
118
|
+
if (!s || s.state === 'exited' || !s.origin) continue;
|
|
119
|
+
if (/^chat:/.test(String(s.origin))) continue;
|
|
120
|
+
if (except && (s.id === except || String(s.origin).startsWith(except))) continue;
|
|
121
|
+
if (String(s.initiative || '') === ini || initiativeOfOrigin(s.origin) === ini) return s;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/** The initiative an origin names: drive:initiative:<id>, grill:<ws>:<id>:<run>, propose:<ws>:<id>:<run>. */
|
|
126
|
+
function initiativeOfOrigin(origin) {
|
|
127
|
+
const p = String(origin || '').split(':');
|
|
128
|
+
if (p[0] === 'drive' && p[1] === 'initiative') return p.slice(2).join(':') || null;
|
|
129
|
+
if ((p[0] === 'grill' || p[0] === 'propose') && p.length >= 4) return p[2] || null;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* origin prefix → queue job. Origins not listed (a refresh source, a schedule, next-step) keep their prefix as the job name. */
|
|
134
|
+
const ORIGIN_JOB = Object.freeze({ drive: 'driver', grill: 'grill', propose: 'propose', 'metric-read': 'metric-reads', 'metric-draft': 'metric-drafts',
|
|
135
|
+
impact: 'impact', coredoc: 'coredocs', 'core-docs': 'coredocs', 'scan-web': 'scan', 'scan-repo': 'scan', 'scan-suggest': 'scan',
|
|
136
|
+
'scan-research-web': 'scan', 'scan-research-repo': 'scan', 'report-find': 'finder', 'ms-review': 'milestones', 'next-step': 'prepare' });
|
|
137
|
+
function jobOfOrigin(origin) {
|
|
138
|
+
const o = String(origin || '');
|
|
139
|
+
if (!o || /^chat:/.test(o)) return null;
|
|
140
|
+
const head = o.split(':')[0];
|
|
141
|
+
return ORIGIN_JOB[head] || head || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** host: { stateDir, idleFor(now) → ms, idleLimit, quiet(now) → bool, running() → number, offOf(id) → null|reason, spend() → { atCap, why? },
|
|
145
|
+
* workEnd() → 'HH:MM' | null, sessions() → [session], warn(what, e), env? } */
|
|
146
|
+
function create(host = {}) {
|
|
147
|
+
const fns = new Map();
|
|
148
|
+
const lastWhy = new Map(); // job → the why of its last turn ('' = it went)
|
|
149
|
+
let last = { at: null, rows: [] };
|
|
150
|
+
const warn = host.warn || (() => {});
|
|
151
|
+
const env = host.env || process.env;
|
|
152
|
+
const call = (f, d) => { try { const v = f(); return v === undefined ? d : v; } catch (e) { warn('bg-queue', e); return d; } };
|
|
153
|
+
|
|
154
|
+
function idle(now = Date.now()) {
|
|
155
|
+
const fixed = Number(env.PMOS_QUEUE_IDLE_MS);
|
|
156
|
+
if (env.PMOS_QUEUE_IDLE_MS !== undefined && Number.isFinite(fixed)) return fixed;
|
|
157
|
+
return host.idleFor ? call(() => host.idleFor(now), 0) : 0;
|
|
158
|
+
}
|
|
159
|
+
function noteTurn(now, r) {
|
|
160
|
+
const why = r.go ? '' : r.why;
|
|
161
|
+
if (lastWhy.get(r.id) === why) return;
|
|
162
|
+
const first = !lastWhy.has(r.id);
|
|
163
|
+
lastWhy.set(r.id, why);
|
|
164
|
+
if (first && r.go) return; // a job that goes from the start is the steady state: nothing to write
|
|
165
|
+
if (!host.stateDir) return;
|
|
166
|
+
append({ at: new Date(now).toISOString(), kind: 'turn', job: r.id, step: r.step, go: r.go, why: r.why });
|
|
167
|
+
}
|
|
168
|
+
const file = () => (host.stateDir ? path.join(host.stateDir, 'bg-queue.jsonl') : null);
|
|
169
|
+
function append(line) {
|
|
170
|
+
if (!file()) return;
|
|
171
|
+
try { fs.mkdirSync(host.stateDir, { recursive: true }); fs.appendFileSync(file(), JSON.stringify(line) + '\n'); } catch (e) { warn('bg-queue record', e); }
|
|
172
|
+
}
|
|
173
|
+
function ended(s, now = Date.now()) {
|
|
174
|
+
if (!s || !s.origin) return;
|
|
175
|
+
const job = jobOfOrigin(s.origin);
|
|
176
|
+
if (!job) return;
|
|
177
|
+
const line = { at: new Date(now).toISOString(), kind: 'done', job, title: String(s.name || job).slice(0, 160), ws: s.ws || null, session: s.id || null };
|
|
178
|
+
const ini = s.initiative || initiativeOfOrigin(s.origin);
|
|
179
|
+
if (ini) line.ini = String(ini);
|
|
180
|
+
if (s.outcome && typeof s.outcome === 'string') line.meta = s.outcome.slice(0, 160);
|
|
181
|
+
append(line);
|
|
182
|
+
}
|
|
183
|
+
function record({ ws = null, since = null } = {}) {
|
|
184
|
+
let text = '';
|
|
185
|
+
try { text = file() ? fs.readFileSync(file(), 'utf8') : ''; } catch { return []; }
|
|
186
|
+
const from = since == null ? -Infinity : (typeof since === 'number' ? since : Date.parse(since));
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const raw of text.split('\n')) {
|
|
189
|
+
if (!raw.trim()) continue;
|
|
190
|
+
let l; try { l = JSON.parse(raw); } catch { continue; }
|
|
191
|
+
if (!l || l.kind !== 'done') continue;
|
|
192
|
+
if (ws && l.ws && l.ws !== ws) continue;
|
|
193
|
+
if (Date.parse(l.at) < from) continue;
|
|
194
|
+
const row = { job: l.job, title: l.title, at: l.at };
|
|
195
|
+
if (l.meta) row.meta = l.meta;
|
|
196
|
+
if (l.ini) row.ini = l.ini;
|
|
197
|
+
if (l.board) row.board = l.board;
|
|
198
|
+
out.push(row);
|
|
199
|
+
}
|
|
200
|
+
return out.reverse();
|
|
201
|
+
}
|
|
202
|
+
function tick(now = Date.now()) {
|
|
203
|
+
const jobs = JOBS.filter((j) => fns.has(j.id));
|
|
204
|
+
const rows = plan({
|
|
205
|
+
jobs, now, idleMs: idle(now), idleLimit: host.idleLimit, quiet: !!(host.quiet && call(() => host.quiet(now), false)),
|
|
206
|
+
running: host.running ? call(() => host.running(), 0) : 0, offOf: (id) => (host.offOf ? call(() => host.offOf(id), null) : null),
|
|
207
|
+
spend: host.spend ? call(() => host.spend(now), null) : null, workEnd: host.workEnd ? call(() => host.workEnd(), null) : null,
|
|
208
|
+
});
|
|
209
|
+
for (const r of rows) {
|
|
210
|
+
noteTurn(now, r);
|
|
211
|
+
if (!r.go) continue;
|
|
212
|
+
try { fns.get(r.id)({ now, idle: r.idle, step: r.step }); } catch (e) { warn(r.id + ' tick', e); }
|
|
213
|
+
}
|
|
214
|
+
last = { at: now, rows };
|
|
215
|
+
return rows;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
register(id, fn) { if (!BY_ID.has(id)) throw new Error(`bg-queue: ${id} is not a job in JOBS`); fns.set(id, fn); },
|
|
219
|
+
tick, idle, ended, record,
|
|
220
|
+
status: () => ({ at: last.at ? new Date(last.at).toISOString() : null, cap: CAP, steps: STEPS,
|
|
221
|
+
jobs: last.rows.map((r) => ({ ...r, label: BY_ID.get(r.id).label })) }),
|
|
222
|
+
holder: (initiative, except) => holderOf(host.sessions ? call(() => host.sessions(), []) : [], initiative, { except }),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { CAP, STEPS, JOBS, plan, holderOf, initiativeOfOrigin, jobOfOrigin, create };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* connector-read.js — which connector tools are read-shaped. Shared by project setup (lib/project-onboard.js) and background runs
|
|
3
|
+
* (lib/headless.js connectorReads). Kept in its own file so a change to project setup does not reach every suite that uses headless runs. */
|
|
4
|
+
const READ_VERBS = new Set(['search', 'get', 'list', 'read', 'fetch', 'find', 'query', 'lookup', 'view', 'describe', 'retrieve', 'browse', 'resolve']);
|
|
5
|
+
const WRITE_VERBS = new Set(['create', 'update', 'delete', 'add', 'post', 'send', 'write', 'edit', 'move', 'transition', 'comment', 'assign', 'upload',
|
|
6
|
+
'remove', 'set', 'merge', 'close', 'archive', 'share', 'copy', 'trash', 'label', 'mark', 'apply', 'schedule', 'reply', 'forward', 'draft', 'respond',
|
|
7
|
+
'invite', 'publish', 'run', 'execute', 'trigger', 'link', 'unlink', 'approve', 'submit', 'save', 'log', 'record', 'register', 'insert', 'append',
|
|
8
|
+
'clear', 'fork', 'enable', 'disable', 'untrash', 'unmark', 'unlabel', 'cancel', 'install', 'import', 'export', 'generate', 'use', 'push', 'request', 'rename']);
|
|
9
|
+
const words = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
10
|
+
|
|
11
|
+
/** A tool may be granted only when its own name (after the server prefix) leads with a read verb and holds no write verb. */
|
|
12
|
+
function readTool(toolName, prefix) {
|
|
13
|
+
const own = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '');
|
|
14
|
+
const w = words(own);
|
|
15
|
+
if (!w.length || w.some((x) => WRITE_VERBS.has(x))) return false;
|
|
16
|
+
return w.slice(0, 3).some((x) => READ_VERBS.has(x));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { readTool, READ_VERBS, WRITE_VERBS };
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* deps: { gather?(w, ctx, opts) → facts answer (tests), workspaces() → [w], isLead(w), stateDirFor(w), ctx (for lib/routes-boards.js), runtimeDir, settings() → proactive settings,
|
|
17
17
|
* inQuiet(now, quiet) → bool, spawn(spec) → session row, promptFor(kind) → skill body | null, boardOf(w, id) → board id | null,
|
|
18
|
-
* homeId() → the workspace id that holds the Work tasks, headlessOff() → bool, env, warn(what, err)
|
|
18
|
+
* homeId() → the workspace id that holds the Work tasks, headlessOff() → bool, env, warn(what, err),
|
|
19
|
+
* holder?(initiative) → the live background run of another job on it, or null (lib/bg-queue.js: one run per initiative) }
|
|
19
20
|
* `spawn(spec)` gets { name, ws, origin, initialInput, initiative, onDone }; it returns the session row ({ id, state, headless }).
|
|
20
21
|
*/
|
|
21
22
|
const fs = require('fs');
|
|
@@ -249,6 +250,12 @@ function create(deps) {
|
|
|
249
250
|
try { g = deps.mayStart({ running: live }) || g; } catch (e) { warn('driver gate', e); }
|
|
250
251
|
if (!g.allowed) { summary.held = g.reason || 'the capacity governor'; break; }
|
|
251
252
|
}
|
|
253
|
+
// bg-queue: one background run per initiative. Another job's run holds this one; its wakes stay queued for the next tick.
|
|
254
|
+
if (deps.holder && group.target.kind === 'initiative') {
|
|
255
|
+
let h = null;
|
|
256
|
+
try { h = deps.holder(group.target.id); } catch (e) { warn('driver holder', e); }
|
|
257
|
+
if (h) { summary.held = `another background run is working on ${group.target.id}`; continue; }
|
|
258
|
+
}
|
|
252
259
|
startRun(w, file, group, state, factsNow, now);
|
|
253
260
|
if (deps.started) { try { deps.started(); } catch { /* a counter */ } }
|
|
254
261
|
live = active.size; summary.started++;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* first-reveal.js — what Home shows right after setup (onboarding build B2; frames ObA2First and ObA5Aha, In development;
|
|
3
|
+
* brief /mnt/project-files/onboarding-grill/brief-B2-after-setup-reveal.md).
|
|
4
|
+
*
|
|
5
|
+
* After "Set up PMOS in <folder>" the PM lands on Home with the product already read. This module turns what setup really did and
|
|
6
|
+
* really read into four things, and nothing else:
|
|
7
|
+
*
|
|
8
|
+
* ticks one line per step that finished, with its time from the start of setup ("Folder ready: ~/PMOS/checkout · 0:02")
|
|
9
|
+
* portrait "Here is <product> as I see it": a header and up to three cells (THE GOAL, THE TIME, THE WORK). A cell is drawn only when a
|
|
10
|
+
* source returned its facts (zero is silence, proven-only). Every cell names its source.
|
|
11
|
+
* draft the first initiative, drafted from the purpose: title, workflow, Triage, expected outcome, check date, owner, the next step.
|
|
12
|
+
* Nothing is written until the PM presses ✓ (keep below).
|
|
13
|
+
* left what Home's list shows from the start: the draft row, the weekly, the PM's own tickets.
|
|
14
|
+
*
|
|
15
|
+
* model({ st, journal, ws, today, workflow }) → { ok, name, ticks, portrait, draft, quiet, left } PURE
|
|
16
|
+
* draftOf(st, { today, workflow }) → the draft alone PURE
|
|
17
|
+
* keep(ctx, { ws, outcome?, actor }) → { ok, id, outcome?, horizon?, anchor } the PM's ✓
|
|
18
|
+
* read(runtimeDir, ws) / write(runtimeDir, ws, v) the decision per product: { kept: { id, at } } | { dropped: at }
|
|
19
|
+
*
|
|
20
|
+
* WHERE THE FACTS COME FROM. The onboarding progress record (lib/routes-onboard.js, runtime folder): project, review.fields (each field with
|
|
21
|
+
* its source), found.findings and found.facts, and the init journal (each step's time). found.facts is what the read-only search returned as
|
|
22
|
+
* numbers, each citing one finding (lib/project-onboard.js search): jira { key, open, mine, due_week }, weekly { title, weekday, time, next },
|
|
23
|
+
* goal { metric, now, target }, timeline { start, end }. Facts of a finding that does not exist are dropped there, before they reach here.
|
|
24
|
+
*
|
|
25
|
+
* WHAT ✓ WRITES (keep). One initiative, through the Board's own Add an ask + Accept (lib/routes-boards.js writeAs: the record by
|
|
26
|
+
* lib/initiative-writer.js, its place in Triage on the product's board, the default workflow). Then its expected outcome and check date
|
|
27
|
+
* (initiative-writer setOutcome) and its description from the purpose (setDescription). The anchor is the product's first key result. A
|
|
28
|
+
* product with no key result yet gets one goal file with ONE key result, the expected outcome the PM just kept (the card says so before ✓):
|
|
29
|
+
* an initiative is never written unanchored (D59), and never anchored to an engineering health budget it does not serve.
|
|
30
|
+
*/
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
|
|
35
|
+
const DAY = 864e5;
|
|
36
|
+
const DEFAULT_DAYS = 90; // the same default as lib/outcomes.js when no date is cited
|
|
37
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
38
|
+
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
39
|
+
|
|
40
|
+
const one = (v, n = 300) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
41
|
+
const clip = (s, n) => { const t = one(s, 2000); if (t.length <= n) return t; const c = t.slice(0, n - 1); return (c.slice(0, c.lastIndexOf(' ')) || c).replace(/[,;:\s]+$/, '') + '…'; };
|
|
42
|
+
const isDay = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s || '')) && Number.isFinite(Date.parse(s + 'T00:00:00Z'));
|
|
43
|
+
const dn = (d) => Math.round(Date.parse(d + 'T00:00:00Z') / DAY);
|
|
44
|
+
const isoN = (n) => new Date(n * DAY).toISOString().slice(0, 10);
|
|
45
|
+
const md = (d) => { const t = new Date(Date.parse(d + 'T00:00:00Z')); return t.getUTCDate() + ' ' + MON[t.getUTCMonth()]; };
|
|
46
|
+
const ms = (v) => { const t = Date.parse(v || ''); return Number.isFinite(t) ? t : null; };
|
|
47
|
+
const clock = (n) => { const s = Math.max(0, Math.round(n / 1000)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
|
|
48
|
+
const num = (v) => { const m = /-?\d+(?:[.,]\d+)?/.exec(String(v == null ? '' : v)); return m ? Number(m[0].replace(',', '.')) : null; };
|
|
49
|
+
const tilde = (p, home) => (home && p && (p === home || p.startsWith(home + path.sep)) ? '~' + p.slice(home.length) : p);
|
|
50
|
+
const initials = (name) => one(name, 80).split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || 'P';
|
|
51
|
+
const plural = (n, a, b) => n + ' ' + (n === 1 ? a : b);
|
|
52
|
+
const val = (f) => (f && typeof f === 'object' && one(f.value) ? one(f.value, 400) : '');
|
|
53
|
+
|
|
54
|
+
/* ── the decision store (per user, never in a repository) ── */
|
|
55
|
+
function fileOf(runtimeDir, ws) { return path.join(runtimeDir, 'onboarding', 'reveal-' + crypto.createHash('sha256').update(String(ws)).digest('hex').slice(0, 16) + '.json'); }
|
|
56
|
+
function read(runtimeDir, ws) { try { const v = JSON.parse(fs.readFileSync(fileOf(runtimeDir, ws), 'utf8')); return v && typeof v === 'object' ? v : {}; } catch { return {}; } }
|
|
57
|
+
function write(runtimeDir, ws, v) {
|
|
58
|
+
const f = fileOf(runtimeDir, ws); fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
59
|
+
const tmp = f + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
|
|
60
|
+
try { fs.writeFileSync(tmp, JSON.stringify({ ...v, ws, updated_at: new Date().toISOString() }) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, f); }
|
|
61
|
+
finally { try { fs.unlinkSync(tmp); } catch {} }
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── the facts, with their sources ── */
|
|
66
|
+
function findingOf(st, id) { return ((st.found && st.found.findings) || []).find((f) => f.id === id) || null; }
|
|
67
|
+
/** A fact is used only when the finding it cites is there: its source label and title go under the cell. */
|
|
68
|
+
function fact(st, k) {
|
|
69
|
+
const x = st.found && st.found.facts && st.found.facts[k];
|
|
70
|
+
if (!x || typeof x !== 'object') return null;
|
|
71
|
+
const f = findingOf(st, x.finding);
|
|
72
|
+
return f ? { ...x, src: { label: f.source_label, title: f.title, url: f.url || null } } : null;
|
|
73
|
+
}
|
|
74
|
+
function srcOfField(f) {
|
|
75
|
+
if (!f) return null;
|
|
76
|
+
if (f.source === 'finding') return { label: f.source_label, title: f.title || '', url: f.url || null };
|
|
77
|
+
if (f.source === 'folder') return { label: 'The folder', title: f.title || '' };
|
|
78
|
+
return { label: 'You', title: '' };
|
|
79
|
+
}
|
|
80
|
+
function fieldsOf(st) { return (st.review && st.review.fields) || {}; }
|
|
81
|
+
function jiraKeyOf(st) {
|
|
82
|
+
const j = fact(st, 'jira'); if (j && /^[A-Z][A-Z0-9_]{1,19}$/.test(String(j.key || ''))) return j.key;
|
|
83
|
+
const rv = st.review && st.review.jira; return rv && rv !== 'none' ? rv : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── the draft ── */
|
|
87
|
+
/** The title from the purpose: its first clause, as a short line; the product's name when there is no purpose. */
|
|
88
|
+
function titleOf(purpose, name) {
|
|
89
|
+
const p = one(purpose, 400).replace(/^(we|i)\s+(want|need|aim|plan)\s+to\s+/i, '').replace(/^(to|so that)\s+/i, '');
|
|
90
|
+
const first = (p.split(/(?<=[.!?])\s|;|\s[—–]\s/)[0] || '').replace(/[.!?]+$/, '').trim();
|
|
91
|
+
const t = first ? clip(first, 70).replace(/…$/, '') : one(name, 70);
|
|
92
|
+
return t ? t[0].toUpperCase() + t.slice(1) : 'First initiative';
|
|
93
|
+
}
|
|
94
|
+
function outcomeOf(st) {
|
|
95
|
+
const g = fact(st, 'goal');
|
|
96
|
+
if (g && one(g.metric)) {
|
|
97
|
+
const now = one(g.now, 24), target = one(g.target, 24);
|
|
98
|
+
return { metric: clip(one(g.metric, 200) + (now && target ? ' from ' + now + ' to ' + target : target ? ' to ' + target : ''), 300), src: g.src };
|
|
99
|
+
}
|
|
100
|
+
const goals = fieldsOf(st).goals;
|
|
101
|
+
if (val(goals) && val(goals).length >= 8) return { metric: clip(val(goals), 300), src: srcOfField(goals) };
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function horizonOf(st, today) {
|
|
105
|
+
const t = fact(st, 'timeline');
|
|
106
|
+
if (t && isDay(t.end) && dn(t.end) >= dn(today) + 7) return { day: t.end, why: 'the end of the timeline', src: t.src };
|
|
107
|
+
return { day: isoN(dn(today) + DEFAULT_DAYS), why: DEFAULT_DAYS + ' days from today', src: null };
|
|
108
|
+
}
|
|
109
|
+
function nextOf(st) {
|
|
110
|
+
const docs = ((st.found && st.found.findings) || []).filter((f) => /^(page|document)$/.test(f.kind)).slice(0, 2).map((f) => 'the ' + clip(f.title, 40).replace(/^the\s+/i, ''));
|
|
111
|
+
const j = fact(st, 'jira'), key = jiraKeyOf(st);
|
|
112
|
+
const parts = docs.slice();
|
|
113
|
+
if (j && Number.isFinite(num(j.open)) && num(j.open) > 0) parts.push(plural(num(j.open), 'ticket', 'tickets') + (key ? ' in ' + key : ''));
|
|
114
|
+
const from = parts.length ? ' from ' + (parts.length > 1 ? parts.slice(0, -1).join(', ') + ' and ' + parts[parts.length - 1] : parts[0]) : '';
|
|
115
|
+
return 'Next: a BRD' + from + '. I’ll draft it once you keep this.';
|
|
116
|
+
}
|
|
117
|
+
function draftOf(st, { today, workflow = 'Product feature' } = {}) {
|
|
118
|
+
const f = fieldsOf(st), name = val(f.name) || one(st.project && st.project.name, 120);
|
|
119
|
+
const purpose = val(f.purpose);
|
|
120
|
+
const out = outcomeOf(st), h = horizonOf(st, today);
|
|
121
|
+
return {
|
|
122
|
+
title: titleOf(purpose, name), workflow, stage: 'Triage', purpose, purpose_src: srcOfField(f.purpose),
|
|
123
|
+
outcome: out ? out.metric : '', outcome_src: out ? out.src : null,
|
|
124
|
+
horizon: h.day, horizon_label: md(h.day), horizon_why: h.why, horizon_src: h.src,
|
|
125
|
+
owner: val(f.owner) || 'You', owner_src: val(f.owner) ? srcOfField(f.owner) : null,
|
|
126
|
+
next: nextOf(st),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What the initiative's chat is asked once the PM keeps the draft: the BRD, from the same sources, with its open questions marked. */
|
|
131
|
+
function brdPrompt(st, draft) {
|
|
132
|
+
const docs = ((st.found && st.found.findings) || []).filter((f) => /^(page|document)$/.test(f.kind)).slice(0, 3).map((f) => f.title + (f.url ? ' (' + f.url + ')' : ''));
|
|
133
|
+
const key = jiraKeyOf(st);
|
|
134
|
+
return ['Draft the BRD for this initiative, "' + draft.title + '", in Triage.', docs.length ? 'Read ' + docs.join('; ') + '.' : '', key ? 'Read the open tickets in Jira ' + key + ', read-only.' : '',
|
|
135
|
+
'Mark every open question as a question, and do not guess an answer. Then open the BRD in the Viewer at its open questions.'].filter(Boolean).join(' ');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ── the portrait ── */
|
|
139
|
+
function goalCell(st) {
|
|
140
|
+
const g = fact(st, 'goal');
|
|
141
|
+
if (g && one(g.metric)) {
|
|
142
|
+
const now = num(g.now), target = num(g.target);
|
|
143
|
+
const pct = Number.isFinite(now) && Number.isFinite(target) && target > 0 ? Math.max(0, Math.min(100, Math.round((now / target) * 100))) : null;
|
|
144
|
+
return { key: 'goal', label: 'THE GOAL', metric: one(g.metric, 120), now: one(g.now, 24), target: one(g.target, 24), pct, src: g.src };
|
|
145
|
+
}
|
|
146
|
+
const goals = fieldsOf(st).goals;
|
|
147
|
+
if (val(goals) && goals.source !== 'you') return { key: 'goal', label: 'THE GOAL', metric: clip(val(goals), 120), now: '', target: '', pct: null, src: srcOfField(goals) };
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/** The weekly's dates from the next one on, every 7 days, up to the end (at most 12). */
|
|
151
|
+
function weeklyDates(w, today, end) {
|
|
152
|
+
let start = isDay(w.next) ? dn(w.next) : null;
|
|
153
|
+
if (start == null) {
|
|
154
|
+
const wd = WEEKDAYS.indexOf(String(w.weekday || '').slice(0, 3).toLowerCase());
|
|
155
|
+
if (wd < 0) return [];
|
|
156
|
+
const t = dn(today), cur = new Date(t * DAY).getUTCDay();
|
|
157
|
+
start = t + ((wd - cur + 7) % 7);
|
|
158
|
+
}
|
|
159
|
+
const out = [];
|
|
160
|
+
for (let d = start; d <= end && out.length < 12; d += 7) out.push(isoN(d));
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function timeCell(st, draft, today) {
|
|
164
|
+
const t = fact(st, 'timeline'), w = fact(st, 'weekly');
|
|
165
|
+
if (!t && !w) return null;
|
|
166
|
+
const start = t && isDay(t.start) ? Math.min(dn(t.start), dn(today)) : dn(today);
|
|
167
|
+
const end = Math.max(t && isDay(t.end) ? dn(t.end) : 0, dn(draft.horizon), start + 28);
|
|
168
|
+
const at = (d) => Math.round(((dn(d) - start) / (end - start)) * 1000) / 10;
|
|
169
|
+
const months = [];
|
|
170
|
+
for (let d = new Date(start * DAY); d.getTime() <= end * DAY; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) {
|
|
171
|
+
const day = d.toISOString().slice(0, 10); months.push({ label: MON[d.getUTCMonth()], at: Math.max(0, at(day)) });
|
|
172
|
+
}
|
|
173
|
+
const weekly = w ? weeklyDates(w, today, end).map((d) => ({ day: d, at: at(d) })) : [];
|
|
174
|
+
const line = t && isDay(t.start) && isDay(t.end) ? md(t.start) + ' to ' + md(t.end) : '';
|
|
175
|
+
const meet = w ? [one(w.title, 60), [one(w.weekday, 12), one(w.time, 8)].filter(Boolean).join(' ')].filter(Boolean).join(' · ') : '';
|
|
176
|
+
return { key: 'time', label: 'THE TIME', line, meet, months, today: at(today), weekly, check: { day: draft.horizon, label: md(draft.horizon), at: at(draft.horizon) },
|
|
177
|
+
src: [t && t.src, w && w.src].filter(Boolean) };
|
|
178
|
+
}
|
|
179
|
+
function workCell(st) {
|
|
180
|
+
const j = fact(st, 'jira');
|
|
181
|
+
if (!j || !Number.isFinite(num(j.open))) return null;
|
|
182
|
+
const mine = num(j.mine), due = num(j.due_week);
|
|
183
|
+
return { key: 'work', label: 'THE WORK', key_: jiraKeyOf(st), open: num(j.open), mine: Number.isFinite(mine) ? mine : null, due_week: Number.isFinite(due) ? due : null,
|
|
184
|
+
read_at: (st.found && st.found.at) || null, src: j.src };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ── the ticks ── */
|
|
188
|
+
function ticksOf(st, journal, home) {
|
|
189
|
+
const steps = (journal && journal.steps) || (st.init && st.init.steps) || {};
|
|
190
|
+
const found = st.found || null;
|
|
191
|
+
const t0 = [found && found.started_at, found && found.at, st.discovered && st.discovered.at, journal && journal.started_at].map(ms).filter((x) => x != null).reduce((a, b) => Math.min(a, b), Infinity);
|
|
192
|
+
if (!Number.isFinite(t0)) return [];
|
|
193
|
+
const out = [];
|
|
194
|
+
const add = (at, text) => { const t = ms(at); if (t != null) out.push({ t, text, at: clock(t - t0) }); };
|
|
195
|
+
const root = st.done && st.done.root;
|
|
196
|
+
if (steps.folder && steps.folder.state === 'done') add(steps.folder.at, 'Folder ready: ' + tilde(root || (journal && journal.root) || '', home));
|
|
197
|
+
if (found) {
|
|
198
|
+
const read = (found.sources || []).filter((s) => s.state === 'found' || s.state === 'empty');
|
|
199
|
+
const docs = (found.findings || []).filter((f) => /^(page|document)$/.test(f.kind)).slice(0, 1).map((f) => 'the ' + clip(f.title, 40).replace(/^the\s+/i, ''));
|
|
200
|
+
const key = jiraKeyOf(st);
|
|
201
|
+
const names = docs.concat(key && read.some((s) => /atlassian|jira/i.test(s.label)) ? ['Jira ' + key] : [])
|
|
202
|
+
.concat(read.filter((s) => !(key && /atlassian|jira/i.test(s.label)) && !(docs.length && (found.findings || []).some((f) => f.source === s.id && /^(page|document)$/.test(f.kind)))).map((s) => s.label));
|
|
203
|
+
if (names.length) add(found.at, 'Read ' + (names.length > 1 ? names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1] : names[0]) + ', read-only');
|
|
204
|
+
const w = fact(st, 'weekly');
|
|
205
|
+
if (w) add(found.at, 'Found your ' + clip(w.title || 'weekly', 40).replace(/^your\s+/i, '') + ' in ' + w.src.label);
|
|
206
|
+
}
|
|
207
|
+
if (steps.kit && steps.kit.state === 'done') add(steps.kit.at, steps.kit.detail === 'reused' ? 'PMOS was already there, kept as it is' : 'PMOS added');
|
|
208
|
+
if (steps.context && steps.context.state === 'done') add(steps.context.at, 'Project context saved');
|
|
209
|
+
return out.sort((a, b) => a.t - b.t).map(({ text, at }) => ({ text, at }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ── the whole reveal ── */
|
|
213
|
+
function model({ st, journal = null, ws, today, workflow = 'Product feature', decided = {}, home = '', hasKr = true }) {
|
|
214
|
+
if (!st || !st.done || st.done.ws !== ws) return { ok: true, none: true };
|
|
215
|
+
const f = fieldsOf(st), name = val(f.name) || st.done.name || one(st.project && st.project.name, 120);
|
|
216
|
+
const draft = draftOf(st, { today, workflow });
|
|
217
|
+
const cells = [goalCell(st), timeCell(st, draft, today), workCell(st)].filter(Boolean);
|
|
218
|
+
const timeline = fact(st, 'timeline');
|
|
219
|
+
const header = { name, initials: initials(name), line: [val(f.team), workflow, timeline && isDay(timeline.end) ? 'until ' + md(timeline.end) : val(f.timeline) ? clip(val(f.timeline), 40) : ''].filter(Boolean) };
|
|
220
|
+
const miss = [!val(f.owner) && 'Owner', !val(f.goals) && !fact(st, 'goal') && 'goals'].filter(Boolean);
|
|
221
|
+
const quiet = miss.length ? (miss.length === 2 ? 'Owner and goals were' : miss[0][0].toUpperCase() + miss[0].slice(1) + (miss[0] === 'goals' ? ' were' : ' was')) + ' not in the notes. Say ' + (miss.length === 2 || miss[0] === 'goals' ? 'them' : 'it') + ' here any time; the portrait updates.' : '';
|
|
222
|
+
const w = fact(st, 'weekly'), work = workCell(st);
|
|
223
|
+
const state = decided.kept ? 'kept' : decided.dropped ? 'dropped' : 'open';
|
|
224
|
+
const left = {
|
|
225
|
+
draft: state === 'open' ? { title: draft.title, stage: 'Triage', check: draft.horizon_label } : null,
|
|
226
|
+
weekly: w ? { title: one(w.title, 80), when: [one(w.weekday, 12), one(w.time, 8)].filter(Boolean).join(' '), next: isDay(w.next) ? md(w.next) : '' } : null,
|
|
227
|
+
mywork: work && work.mine != null && work.key_ ? { key: work.key_, mine: work.mine, due_week: work.due_week } : null,
|
|
228
|
+
};
|
|
229
|
+
return { ok: true, ws, name, at: st.done.at, state, kept: decided.kept || null,
|
|
230
|
+
ticks: ticksOf(st, journal, home), portrait: { header, cells, said: one(st.project && st.project.description, 400) }, draft: { ...draft, goal_new: !hasKr }, quiet, left,
|
|
231
|
+
brd: brdPrompt(st, draft) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ── ✓ keep ── */
|
|
235
|
+
function laneOf(root) {
|
|
236
|
+
const rows = require('./initiative-rows.js');
|
|
237
|
+
let groups = {}; try { groups = rows.workGroupOf(root); } catch { /* the writer says what is wrong */ }
|
|
238
|
+
const work = Object.keys(groups).filter((l) => groups[l] === 'work');
|
|
239
|
+
return ['feature', 'new-feature'].find((l) => work.includes(l)) || work[0] || 'feature';
|
|
240
|
+
}
|
|
241
|
+
/** The product's first key result, or null. */
|
|
242
|
+
const DRAFT_GOALS = 'okrs-draft.md'; // the setup's PROPOSED goal (onboarding B1): not a goal the PM kept, so not an anchor here
|
|
243
|
+
function keptOkrFiles(root) {
|
|
244
|
+
try { return (require('./okr-goals.js').read(root).files || []).filter((f) => path.basename(f) !== DRAFT_GOALS); } catch { return []; }
|
|
245
|
+
}
|
|
246
|
+
function firstKr(root) {
|
|
247
|
+
const rows = require('./initiative-rows.js');
|
|
248
|
+
if (!keptOkrFiles(root).length) return null;
|
|
249
|
+
try { const a = rows.anchorsOf(root).list().find((x) => x.kind === 'kr'); return a ? a.value : null; } catch { return null; }
|
|
250
|
+
}
|
|
251
|
+
function quarterOf(today) { const d = new Date(Date.parse(today + 'T00:00:00Z')); return d.getUTCFullYear() + '-Q' + (Math.floor(d.getUTCMonth() / 3) + 1); }
|
|
252
|
+
/** One goal file with ONE key result: the outcome the PM kept on the card. Never over an existing file. → the anchor */
|
|
253
|
+
function writeGoal(root, { name, purpose, outcome, today }) {
|
|
254
|
+
const pm = fs.existsSync(path.join(root, 'pmos', 'state')) ? path.join(root, 'pmos', 'planning') : path.join(root, 'planning');
|
|
255
|
+
const dir = path.join(pm, 'okrs'); fs.mkdirSync(dir, { recursive: true });
|
|
256
|
+
const q = quarterOf(today);
|
|
257
|
+
let file = path.join(dir, 'okrs-' + q + '.md');
|
|
258
|
+
for (let i = 2; fs.existsSync(file) && i < 50; i++) file = path.join(dir, 'okrs-' + q + '-' + i + '.md');
|
|
259
|
+
const obj = clip(purpose || name, 120).replace(/\n/g, ' ');
|
|
260
|
+
const draft = fs.existsSync(path.join(dir, DRAFT_GOALS)); // the outcome the PM kept replaces the setup's proposed goal (lib/okr-goals.js reads the line)
|
|
261
|
+
const text = ['---', 'type: Reference', `title: "Goals ${q}: ${String(name).replace(/"/g, "'")}"`, `timestamp: ${today}`, '---', '',
|
|
262
|
+
`# Goals ${q}`, '', `period: ${q}`, '', `Written by PMOS when you kept the first initiative. The key result is the expected outcome you kept. Change it here or in Intelligence.`, '',
|
|
263
|
+
...(draft ? ['Supersedes `' + DRAFT_GOALS + '`', ''] : []),
|
|
264
|
+
`## O1 — ${obj}`, '', `- KR1 — ${one(outcome, 300)}`, ''].join('\n');
|
|
265
|
+
fs.writeFileSync(file, text, { flag: 'wx' });
|
|
266
|
+
return { anchor: 'O1.KR1', file: path.relative(root, file) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The PM's ✓. ctx is the route ctx (getWs, runtimeDir, …); g the page press (pm-guard). Writes nothing when the decision was made already.
|
|
271
|
+
* outcome: what the PM typed on the card when the notes named none (optional).
|
|
272
|
+
*/
|
|
273
|
+
function keep(ctx, g, { ws, outcome, today, st }) {
|
|
274
|
+
const decided = read(ctx.runtimeDir, ws);
|
|
275
|
+
if (decided.kept) return { ok: true, id: decided.kept.id, again: true };
|
|
276
|
+
const w = ctx.getWs(ws);
|
|
277
|
+
if (!w || !w.root) return { ok: false, status: 404, error: 'That product is not in My Work any more.' };
|
|
278
|
+
const draft = draftOf(st, { today, workflow: 'Product feature' });
|
|
279
|
+
const metric = one(outcome, 300) || draft.outcome;
|
|
280
|
+
let anchor = firstKr(w.root), goal = null;
|
|
281
|
+
if (!anchor) {
|
|
282
|
+
if (!metric || metric.length < 8) return { ok: false, status: 400, field: 'outcome', error: 'Say what should change once it ships. It becomes the product’s first goal.' };
|
|
283
|
+
try { goal = writeGoal(w.root, { name: draft.title, purpose: draft.purpose || (st.done && st.done.name), outcome: metric, today }); anchor = goal.anchor; }
|
|
284
|
+
catch { return { ok: false, status: 500, error: 'The goal file could not be written. Nothing was saved.' }; }
|
|
285
|
+
}
|
|
286
|
+
const RB = require('./routes-boards.js');
|
|
287
|
+
const a = RB.writeAs(ctx, '/api/boards/asks/add', { ws: w.id, board: w.id, line: draft.title, source: 'Drafted at setup, kept by you' }, g);
|
|
288
|
+
if (!a.body || !a.body.ok) return { ok: false, status: a.status || 500, error: (a.body && a.body.error) || 'The initiative was not added.' };
|
|
289
|
+
const r = RB.writeAs(ctx, '/api/boards/asks/accept', { ws: w.id, board: w.id, ask: a.body.ask.id, title: draft.title, lane: laneOf(w.root), anchor }, g);
|
|
290
|
+
if (!r.body || !r.body.ok) return { ok: false, status: r.status || 500, error: (r.body && r.body.error) || 'The initiative was not started.' };
|
|
291
|
+
const id = r.body.initiative && r.body.initiative.id;
|
|
292
|
+
const W = require('./initiative-writer.js');
|
|
293
|
+
// It starts in the first stage of its workflow (D99: Triage on Product feature). Accept places a new initiative on the default workflow
|
|
294
|
+
// only when the default is not Product feature (routes-boards startOnDefault); on Product feature it would read the board's own first
|
|
295
|
+
// column (Discover). So the drafted initiative gets the line a workflow start writes, and stands in Triage like the card said.
|
|
296
|
+
let stage = r.body.stage || 'Triage';
|
|
297
|
+
try {
|
|
298
|
+
const WF = require(require('./repo-script.js').repoScript('workflows.js')), WS = require('./workflows-store.js');
|
|
299
|
+
const dir = ctx.stateDirFor && ctx.stateDirFor(w);
|
|
300
|
+
const def = dir ? WF.defaultOf(WS.library(dir)) : WF.defaultOf(null);
|
|
301
|
+
if (!r.body.wf && def.id === WF.PRODUCT_FEATURE_ID) { const file = RB.ledgerFile(ctx, w); if (file) RB.append(file, g.actor, { kind: 'ini-wf', board: w.id, id, wf: def.id, why: 'drafted at setup: it starts in Triage', start: true, moved: { from: r.body.stage || null, to: (def.stages[0] && def.stages[0].key) || 'triage' } }); }
|
|
302
|
+
stage = (def.stages[0] && def.stages[0].name) || stage;
|
|
303
|
+
} catch { /* the board's answer */ }
|
|
304
|
+
const wrote = { id, anchor, stage, ...(goal ? { goal: goal.file } : {}) };
|
|
305
|
+
if (metric && metric.length >= 8) { const o = W.setOutcome(w.root, id, { metric, horizon: draft.horizon }, { actor: g.actor, today }); if (o.ok) Object.assign(wrote, { outcome: metric, horizon: draft.horizon }); }
|
|
306
|
+
if (draft.purpose && draft.purpose.length >= 12) { const d = W.setDescription(w.root, id, clip(draft.purpose, W.DESC_MAX || 600), { actor: g.actor }); if (d.ok) wrote.description = true; }
|
|
307
|
+
write(ctx.runtimeDir, ws, { kept: { id, at: new Date().toISOString(), ...wrote } });
|
|
308
|
+
return { ok: true, ...wrote };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = { model, draftOf, keep, read, write, titleOf, weeklyDates, writeGoal, firstKr, DEFAULT_DAYS };
|
|
@@ -215,7 +215,7 @@ const TOOLS = Object.freeze({
|
|
|
215
215
|
// without a prompt. The kinds below read the PM's sources through connectors (Jira, Confluence, Slack, the calendar, mail, Drive), and before
|
|
216
216
|
// dontAsk they relied on the PM's default mode letting a read-only connector tool run. Now the server names those tools: the read-shaped tools
|
|
217
217
|
// of the PM's connected servers (CONNECTOR_READ_SEED, plus what each run's init line reports, server.js), filtered by the same read-verb rule the
|
|
218
|
-
// onboarding search uses (lib/
|
|
218
|
+
// onboarding search uses (lib/connector-read.js readTool). A connector WRITE is never named, so it is still denied. The other kinds read no
|
|
219
219
|
// connector and get none.
|
|
220
220
|
const CONNECTOR_READ_KINDS = Object.freeze(['skill', 'report-setup', 'report-find', 'ms-review', 'propose', 'metric-draft', 'impact', 'metric-read',
|
|
221
221
|
'report-sample-fetch', 'report-structure', 'report-structure-dry', 'report-structure-pull']);
|
|
@@ -235,7 +235,7 @@ const CONNECTOR_READ_SEED = Object.freeze([
|
|
|
235
235
|
const MCP_TOOL_RE = /^mcp__[\w-]+__[\w.-]+$/; // one exact tool: Claude Code's rule syntax, no server-wide or wildcard rule
|
|
236
236
|
/** The read-shaped connector tools among `names` (a run's init line, or a cached list): exact names only, never a write verb. */
|
|
237
237
|
function connectorReads(names) {
|
|
238
|
-
const { readTool } = require('./
|
|
238
|
+
const { readTool } = require('./connector-read.js');
|
|
239
239
|
const out = [];
|
|
240
240
|
for (const n of Array.isArray(names) ? names : []) {
|
|
241
241
|
const t = String(n || '');
|