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.
Files changed (65) hide show
  1. package/bin/create-pmos.js +26 -1
  2. package/package.json +57 -47
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/changelog.json +56 -0
  8. package/workspace/guide/agent-capabilities.md +6 -3
  9. package/workspace/guide/home.md +1 -1
  10. package/workspace/guide/horizon-scan.md +2 -2
  11. package/workspace/guide/idle-lab.md +46 -0
  12. package/workspace/guide/index.md +2 -0
  13. package/workspace/guide/moments.md +43 -0
  14. package/workspace/guide/welcome.md +37 -28
  15. package/workspace/lib/bg-queue.js +226 -0
  16. package/workspace/lib/connector-read.js +19 -0
  17. package/workspace/lib/driver-run.js +8 -1
  18. package/workspace/lib/first-reveal.js +311 -0
  19. package/workspace/lib/headless.js +2 -2
  20. package/workspace/lib/idle-lab.js +275 -0
  21. package/workspace/lib/moments.js +241 -0
  22. package/workspace/lib/morning-goals.js +191 -0
  23. package/workspace/lib/onboard-fundamentals.js +205 -0
  24. package/workspace/lib/onboarding-progress.js +13 -1
  25. package/workspace/lib/orchestrator-spawn.js +9 -2
  26. package/workspace/lib/project-onboard.js +93 -22
  27. package/workspace/lib/proposals.js +9 -5
  28. package/workspace/lib/refresh-run.js +2 -1
  29. package/workspace/lib/route-modules.js +4 -0
  30. package/workspace/lib/routes-lab.js +137 -0
  31. package/workspace/lib/routes-loose.js +1 -0
  32. package/workspace/lib/routes-moments.js +42 -0
  33. package/workspace/lib/routes-morning-goals.js +109 -0
  34. package/workspace/lib/routes-onboard.js +45 -8
  35. package/workspace/lib/routes-proposals.js +12 -2
  36. package/workspace/lib/routes-reveal.js +73 -0
  37. package/workspace/lib/scan-run.js +4 -3
  38. package/workspace/lib/scan.js +5 -3
  39. package/workspace/package-lock.json +2 -2
  40. package/workspace/package.json +1 -1
  41. package/workspace/project-onboard-flow-test.js +70 -57
  42. package/workspace/public/pm/cozy-ev.css +3 -0
  43. package/workspace/public/pm/cozy-lab-ins.js +87 -0
  44. package/workspace/public/pm/cozy-lab.css +86 -0
  45. package/workspace/public/pm/cozy-lab.js +138 -0
  46. package/workspace/public/pm/cozy-nav.js +22 -8
  47. package/workspace/public/pm/cozy-onboard.css +31 -0
  48. package/workspace/public/pm/cozy-onboard.js +217 -111
  49. package/workspace/public/pm/cozy-reveal.css +52 -0
  50. package/workspace/public/pm/cozy-reveal.js +130 -0
  51. package/workspace/public/pm/home-app.js +38 -3
  52. package/workspace/public/pm/home-impact.css +12 -0
  53. package/workspace/public/pm/home-moment.js +41 -0
  54. package/workspace/public/pm/ini-props.js +15 -6
  55. package/workspace/public/pm/premium-board.js +2 -0
  56. package/workspace/public/pm/premium-first.js +1 -1
  57. package/workspace/public/pm/premium-insights.js +6 -1
  58. package/workspace/public/pm/quiet-place.js +31 -0
  59. package/workspace/public/premium-intelligence.html +3 -0
  60. package/workspace/replaced-tests.json +14 -0
  61. package/workspace/server.js +46 -5
  62. package/workspace/test-files.json +2 -0
  63. package/workspace/test-lists/first-reveal.json +9 -0
  64. package/workspace/test-lists/idle-lab.json +9 -0
  65. package/workspace/test-lists/morning-goals.json +9 -0
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+ /* morning-goals.js — the data under Good morning's quarter strip (build brief Good morning, GM1; frames NyG1Morning to NyG4Phone).
3
+ *
4
+ * ONE COUNT. stateOf(g) is the one place a goal's quarter state is decided; the strip, the goal tags and the morning snapshot all read it.
5
+ * g is a goal of the Intelligence model (lib/insights-premium.js goalsOf, decorated by lib/forecast.js), so a goal says the same thing on
6
+ * Good morning and in Intelligence.
7
+ *
8
+ * stateOf(g) → 'reached' | 'on-track' | 'reading' | 'at-risk' | 'will-miss' | null (a watched metric: not a goal)
9
+ * On track only from a read (W2 "Status says what it knows"): a forecast on track, or a fresh reading on its
10
+ * line when the goal has no forecast. Too early, held, a stale reading and no reading are "still reading".
11
+ * collect(products) → [{ key, ws, product, id, title, state, moves }] products: [{ ws, name, goals }]
12
+ * stripOf(list, line) → { order, counts, total, good, line, gap, verdict, why, byProduct }
13
+ * proposeLine(strip) → { line, why } | null Claude's ✦: the goals reached, on track or still in reach. None without a read
14
+ * tagsOf(list) → { "<ws>:<initiative>": { goal, title, state } } the goal an initiative moves (the worst state wins)
15
+ * yesEffect(o) → { scope, id, title, from, to, clause } | null a forecast run again with the proposed date
16
+ * quarterOf(day) · weeksLeft(day)
17
+ *
18
+ * TWO LEDGERS in the runtime folder, append-only:
19
+ * quarter-line.jsonl { at, by, kind: 'line' | 'off', quarter, line? } the PM's line (✓) or the ✦ put off for the quarter (✕)
20
+ * goal-morning.jsonl { at, day, states: { key: state } } the day's first read, once a day
21
+ * lineFor(dir, quarter) → { line, at } | { off: true } | null · setLine(dir, actor, i) · putOff(dir, actor, quarter)
22
+ * snapshotFor(dir, day) → line | null · takeSnapshot(dir, day, list, at) → line (the existing one when the day has one)
23
+ * deltaOf(snapshot, list) → { at, from, to, moved: [{ key, title, product, from, to }] } | null
24
+ *
25
+ * NOTHING HERE MAKES A NUMBER UP: a state comes from a forecast or a reading the model holds, the line from the PM's press. */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const ORDER = Object.freeze(['reached', 'on-track', 'reading', 'at-risk', 'will-miss']);
30
+ const WORDS = Object.freeze({ reached: 'reached', 'on-track': 'on track', reading: 'still reading', 'at-risk': 'at risk', 'will-miss': 'will miss' });
31
+ const GOOD = new Set(['reached', 'on-track']);
32
+ const LINE_FILE = 'quarter-line.jsonl', SNAP_FILE = 'goal-morning.jsonl';
33
+ const DAY = 864e5;
34
+ const fin = (v) => typeof v === 'number' && Number.isFinite(v);
35
+ const str = (v) => (typeof v === 'string' ? v : '');
36
+ const dayMs = (v) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : null; };
37
+ const NUM = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'];
38
+ const say = (n) => (n >= 0 && n < NUM.length ? NUM[n] : String(n));
39
+ const noun = (n, one, many) => n + ' ' + (n === 1 ? one : many);
40
+
41
+ const FORECAST = { 'on-track': 'on-track', 'at-risk': 'at-risk', 'will-miss': 'will-miss', 'too-early': 'reading', held: 'reading' };
42
+ function stateOf(g) {
43
+ if (!g || g.role === 'watch') return null;
44
+ const r = g.reading || null, head = g.pace && g.pace.head;
45
+ if (head === 'Reached' || head === 'Done' || (r && r.line === 'target reached')) return 'reached';
46
+ const fresh = !!(r && r.dot && r.dot !== 'none' && !r.stale);
47
+ if (g.type === 'Stay within') {
48
+ if (!fresh) return 'reading';
49
+ return r.dot === 'broken' ? 'will-miss' : r.dot === 'close' ? 'at-risk' : 'on-track';
50
+ }
51
+ if (g.type === 'Done or not') {
52
+ if (g.pace && g.pace.tone === 'late') return 'will-miss';
53
+ return fresh ? 'on-track' : 'reading';
54
+ }
55
+ const F = g.forecast && FORECAST[g.forecast.state];
56
+ if (F) return F === 'on-track' && !fresh && r ? 'reading' : F;
57
+ if (!fresh) return 'reading';
58
+ return r.dot === 'behind' ? 'at-risk' : 'on-track';
59
+ }
60
+
61
+ function collect(products) {
62
+ const out = [];
63
+ for (const p of products || []) for (const g of (p && p.goals) || []) {
64
+ const state = stateOf(g);
65
+ if (!state || !g.id) continue;
66
+ out.push({ key: p.ws + ':' + g.id, ws: p.ws, product: p.name || p.ws, id: String(g.id), title: str(g.title) || String(g.id), state,
67
+ moves: (g.moves || []).map((m) => m && m.id).filter(Boolean).map(String) });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function stripOf(list, line) {
73
+ const counts = Object.fromEntries(ORDER.map((s) => [s, 0]));
74
+ for (const g of list || []) if (counts[g.state] !== undefined) counts[g.state]++;
75
+ const total = (list || []).length, good = counts.reached + counts['on-track'];
76
+ const L = fin(line) && line > 0 ? Math.min(Math.round(line), total || Math.round(line)) : null;
77
+ const byP = new Map();
78
+ for (const g of list || []) {
79
+ const b = byP.get(g.ws) || { ws: g.ws, product: g.product, total: 0, good: 0, risk: 0, miss: 0, reading: 0 };
80
+ b.total++; if (GOOD.has(g.state)) b.good++; if (g.state === 'at-risk') b.risk++; if (g.state === 'will-miss') b.miss++; if (g.state === 'reading') b.reading++;
81
+ byP.set(g.ws, b);
82
+ }
83
+ const byProduct = [...byP.values()];
84
+ if (!total) return { order: ORDER, words: WORDS, counts, total, good, line: L, gap: null, verdict: '', why: '', byProduct };
85
+ const verdict = noun(good, 'goal', 'goals') + ' on track or better.' + (L ? ' You set the line at ' + L + '.' : '');
86
+ const gap = L ? L - good : null;
87
+ let why = '';
88
+ const worst = byProduct.filter((b) => b.miss + b.risk > 0).sort((a, b) => (b.miss - a.miss) || (b.miss + b.risk - a.miss - a.risk) || a.product.localeCompare(b.product))[0];
89
+ if (gap !== null && gap <= 0) why = gap === 0 ? 'On your line.' : say(-gap) + ' above your line.';
90
+ if (worst && (gap === null || gap > 0)) {
91
+ const lead = gap ? say(gap) + ' short. ' : '';
92
+ const of = (k) => (worst.total === 1 ? 'its one goal' : k + ' of its ' + worst.total + ' goals');
93
+ const part = worst.miss ? of(worst.miss) + ' will miss at today\'s pace'
94
+ : of(worst.risk) + (worst.risk === 1 ? ' is at risk' : ' are at risk');
95
+ why = lead + worst.product + (gap ? ' is most of the gap: ' : ' holds the most at stake: ') + part + '.';
96
+ } else if (gap > 0) why = say(gap) + ' short. ' + noun(counts.reading, 'goal is', 'goals are') + ' still reading.';
97
+ return { order: ORDER, words: WORDS, counts, total, good, line: L, gap, verdict, why, byProduct };
98
+ }
99
+
100
+ /** Claude's ✦ for the quarter line: every goal reached, on track or at risk can still make it; a goal that will miss or is unread is left out. */
101
+ function proposeLine(strip) {
102
+ if (!strip || !strip.total) return null;
103
+ const read = strip.total - strip.counts.reading;
104
+ if (!read) return null;
105
+ const line = strip.counts.reached + strip.counts['on-track'] + strip.counts['at-risk'];
106
+ if (!line) return null;
107
+ const parts = [strip.counts.reached && strip.counts.reached + ' reached', strip.counts['on-track'] && strip.counts['on-track'] + ' on track', strip.counts['at-risk'] && strip.counts['at-risk'] + ' at risk that can still make it'].filter(Boolean);
108
+ return { line, of: strip.total, why: 'Of ' + noun(strip.total, 'goal', 'goals') + ': ' + parts.join(', ') + '. The ones that will miss or are not read yet are left out.' };
109
+ }
110
+
111
+ /** The goal an initiative moves, for the goal tag on its Your move row. Two goals: the one in the worse state. */
112
+ const RANK = { 'will-miss': 0, 'at-risk': 1, reading: 2, 'on-track': 3, reached: 4 };
113
+ function tagsOf(list) {
114
+ const out = {};
115
+ for (const g of list || []) for (const id of g.moves || []) {
116
+ const k = g.ws + ':' + id, had = out[k];
117
+ if (!had || RANK[g.state] < RANK[had.state]) out[k] = { goal: g.id, title: g.title, state: g.state, words: WORDS[g.state] };
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** What Yes does: the forecast run again with the proposed date. o: { goal: model goal, date } or { initiative: { id, title }, jira, date, from }.
123
+ * FC is lib/forecast.js. A clause only when both states are known and differ. */
124
+ function yesEffect(o, FC, today) {
125
+ if (!o || !/^\d{4}-\d{2}-\d{2}$/.test(str(o.date))) return null;
126
+ const word = (s) => WORDS[FORECAST[s] || s] || null;
127
+ if (o.goal) {
128
+ const g = o.goal, was = stateOf(g);
129
+ const re = FC.goalForecast(Object.assign({}, g, { byAt: o.date }), { today });
130
+ const to = re ? FORECAST[re.state] : null;
131
+ if (!was || !to || to === 'reading' || was === 'reading' || to === was) return null;
132
+ return { scope: 'goal', id: g.id, title: g.title, from: was, to, clause: (g.title || 'The goal') + ' moves from ' + WORDS[was] + ' to ' + WORDS[to] + '.' };
133
+ }
134
+ if (o.initiative && o.jira && /^\d{4}-\d{2}-\d{2}$/.test(str(o.from))) {
135
+ const a = FC.deliveryForecast({ end: o.from }, o.jira, { today }), b = FC.deliveryForecast({ end: o.date }, o.jira, { today });
136
+ const from = a && a.state, to = b && b.state;
137
+ if (!from || !to || from === to || from === 'held' || to === 'held' || from === 'too-early' || to === 'too-early') return null;
138
+ return { scope: 'initiative', id: o.initiative.id, title: o.initiative.title || o.initiative.id, from, to, clause: 'Its delivery moves from ' + word(from) + ' to ' + word(to) + '.' };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ const quarterOf = (day) => { const m = /^(\d{4})-(\d{2})/.exec(str(day)); return m ? m[1] + '-Q' + (Math.floor((+m[2] - 1) / 3) + 1) : null; };
144
+ function weeksLeft(day) {
145
+ const t = dayMs(day); if (t === null) return null;
146
+ const d = new Date(t), qEnd = Date.UTC(d.getUTCFullYear(), Math.floor(d.getUTCMonth() / 3) * 3 + 3, 1);
147
+ return Math.max(0, Math.floor((qEnd - t) / (7 * DAY)));
148
+ }
149
+
150
+ /* ── the ledgers ── */
151
+ function readLines(dir, file) {
152
+ if (!dir) return [];
153
+ let text = '';
154
+ try { text = fs.readFileSync(path.join(dir, file), 'utf8'); } catch { return []; }
155
+ const out = [];
156
+ for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const l = JSON.parse(raw); if (l && typeof l === 'object') out.push(l); } catch { /* a torn line is skipped */ } }
157
+ return out;
158
+ }
159
+ function append(dir, file, line) { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(path.join(dir, file), JSON.stringify(line) + '\n'); return line; }
160
+
161
+ function lineFor(dir, quarter) {
162
+ let out = null;
163
+ for (const l of readLines(dir, LINE_FILE)) if (l.quarter === quarter) out = l.kind === 'off' ? { off: true, at: l.at } : l.kind === 'line' && fin(l.line) ? { line: l.line, at: l.at } : out;
164
+ return out;
165
+ }
166
+ function setLine(dir, actor, i) {
167
+ const quarter = str(i && i.quarter), line = Number(i && i.line);
168
+ if (!/^\d{4}-Q[1-4]$/.test(quarter)) return { error: 'quarter is required, as YYYY-Qn' };
169
+ if (!Number.isInteger(line) || line < 1 || line > 500) return { error: 'line is a whole number of goals, 1 or more' };
170
+ return { ok: true, line: append(dir, LINE_FILE, { at: new Date().toISOString(), by: (actor && actor.by) || null, kind: 'line', quarter, line }) };
171
+ }
172
+ function putOff(dir, actor, quarter) {
173
+ if (!/^\d{4}-Q[1-4]$/.test(str(quarter))) return { error: 'quarter is required, as YYYY-Qn' };
174
+ return { ok: true, line: append(dir, LINE_FILE, { at: new Date().toISOString(), by: (actor && actor.by) || null, kind: 'off', quarter }) };
175
+ }
176
+
177
+ function snapshotFor(dir, day) { return readLines(dir, SNAP_FILE).find((l) => l.day === day && l.states && typeof l.states === 'object') || null; }
178
+ function takeSnapshot(dir, day, list, at) {
179
+ const had = snapshotFor(dir, day);
180
+ if (had || !dir || !(list || []).length) return had;
181
+ return append(dir, SNAP_FILE, { at: at || new Date().toISOString(), day, states: Object.fromEntries(list.map((g) => [g.key, g.state])) });
182
+ }
183
+ function deltaOf(snap, list) {
184
+ if (!snap || !snap.states) return null;
185
+ const S = snap.states, keys = Object.keys(S);
186
+ const from = keys.filter((k) => GOOD.has(S[k])).length, to = (list || []).filter((g) => GOOD.has(g.state)).length;
187
+ const moved = (list || []).filter((g) => S[g.key] && S[g.key] !== g.state).map((g) => ({ key: g.key, title: g.title, product: g.product, from: S[g.key], to: g.state }));
188
+ return { at: snap.at, from, to, moved };
189
+ }
190
+
191
+ module.exports = { ORDER, WORDS, LINE_FILE, SNAP_FILE, stateOf, collect, stripOf, proposeLine, tagsOf, yesEffect, quarterOf, weeksLeft, lineFor, setLine, putOff, snapshotFor, takeSnapshot, deltaOf };
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ /* onboard-fundamentals.js — the fundamentals PMOS settles for a new product right after setup (onboarding build B1, frame ObA3Fundamentals).
3
+ *
4
+ * settle({ root, ws, sources, fresh, refresh, launcher, now }) → { at, lines: [line] } reads project-context.md, writes what each line says
5
+ * change(state, key, { root, ws, refresh, launcher }) → { ok, state } the one-press change of one line
6
+ * workflowHint(purpose) → { word } | null a purpose that names work Product feature does not fit. PURE
7
+ * goalFile(name, goal, purpose, today) → the draft OKR document text. PURE
8
+ *
9
+ * A LINE. { key, text, source, change: { label, to } | null, done: bool }. Nothing on the card is a question: each line says what PMOS did and
10
+ * where the fact came from, and its change undoes or flips it with one press. A line with nothing behind it is left out (zero is silence).
11
+ *
12
+ * WHERE THE FACTS COME FROM. Only project-context.md, read back with readContext (the file the PM reviewed): About is its Purpose, the Jira
13
+ * key its Jira line, the weekly its Weekly line, the goal its Goal draft. A source is called linked only when the setup search reached it
14
+ * (`sources`: the search's per-source states), so a Jira key the PM typed but no Jira read proved is said as typed, not as linked.
15
+ *
16
+ * profile D68: pm-artifacts unless the folder holds code (lib/profile-default.js looksLikeCode). Change: flips it.
17
+ * workflow Product feature (D99 wf-default). A purpose that names a tender, vendor onboarding, a campaign … gets a chat link that drafts a
18
+ * copy of Product feature for it, as New workflow does. Nothing is written for this line.
19
+ * jira the key is the product's Jira source: Work assigned to you and the Jira status read are turned on for this product, every
20
+ * weekday morning (lib/refresh-run.js setSource). Change: turns them off again, only if this setup turned them on.
21
+ * calendar a weekly found in a calendar is the proof the calendar answers: the calendar read is turned on. Change: off again.
22
+ * about the Purpose, as the product's description, with its source. Nothing is written beyond project-context.md.
23
+ * goal ONE key result drafted from the purpose, written as PROPOSED to pmos/planning/okrs/okrs-draft.md. It reads nothing and claims no
24
+ * number. Change: removes the file, only while it is the text setup wrote.
25
+ * launcher `pmos launcher on` (an app and a login item), when none is installed. Change: `pmos launcher off`. Only on a real server
26
+ * (lib/profile-default.js enabledFor): a scratch server writes no launcher.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+ const P = require('./project-onboard.js');
32
+ const profileDefault = require('./profile-default.js');
33
+
34
+ const PROFILE = '.pmos-profile';
35
+ const GOAL_FILE = ['planning', 'okrs', 'okrs-draft.md'];
36
+ const MORNING = { kind: 'weekdays', times: ['07:30'] };
37
+ const HINTS = [
38
+ [/\btender(s|ing)?\b|\bprocurement\b|\bRFP\b|\brequest for proposal/i, 'tender'],
39
+ [/\bvendor onboarding\b|\bsupplier onboarding\b|\bonboard(ing)? (new )?vendors?\b/i, 'vendor onboarding'],
40
+ [/\bcampaigns?\b/i, 'campaign'],
41
+ [/\bhiring\b|\brecruit(ing|ment)\b/i, 'hiring'],
42
+ [/\bevents?\b.*\b(launch|conference|summit)\b|\bconference\b/i, 'event'],
43
+ ];
44
+ const clip = (s, n) => { s = String(s || '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s; };
45
+ const sha = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
46
+
47
+ function workflowHint(purpose) {
48
+ const t = String(purpose || '');
49
+ for (const [re, word] of HINTS) if (re.test(t)) return { word };
50
+ return null;
51
+ }
52
+
53
+ function goalFile(name, goal, purpose, today) {
54
+ const objective = (goal && goal.objective) || clip(purpose, 110) || name;
55
+ const kr = goal && goal.kr ? goal.kr : `Choose one number that shows ${name} does what its purpose says`;
56
+ const from = goal && goal.source_label ? `the purpose, as ${goal.source_label} states it` : 'the purpose in project-context.md';
57
+ return ['---', 'type: OKR', `title: "Goals: ${String(name).replace(/"/g, "'")} (draft)"`, `timestamp: ${today}`, '---', '',
58
+ `# Goals: ${name} (draft)`, '',
59
+ `PMOS drafted this during setup from ${from}. It is a draft: nothing reads it, and no number is claimed. Keep it, change it or remove it in Intelligence.`, '',
60
+ `## O1 — ${clip(objective, 110)}`, '',
61
+ `- **KR1** PROPOSED: ${clip(kr, 200)}. Measure: not chosen yet. Source: project-context.md (Purpose).`, ''].join('\n');
62
+ }
63
+
64
+ function contextOf(root) {
65
+ const pm = P.pmosIn(root);
66
+ if (!pm) return { pm: null, file: null, ctx: null };
67
+ const file = path.join(pm.planning, 'project-context.md');
68
+ let text = null; try { text = fs.readFileSync(file, 'utf8'); } catch {}
69
+ return { pm, file, ctx: text == null ? null : P.readContext(text) };
70
+ }
71
+ const answered = (sources, re) => (sources || []).some((s) => re.test(String(s.label || s.id || '')) && (s.state === 'found' || s.state === 'empty'));
72
+ function readProfile(root) { try { return (fs.readFileSync(path.join(root, PROFILE), 'utf8').split('\n')[0] || '').trim(); } catch { return null; } }
73
+ function writeProfile(root, value) {
74
+ const f = path.join(root, PROFILE);
75
+ let rest = ''; try { rest = fs.readFileSync(f, 'utf8').split('\n').slice(1).join('\n'); } catch {}
76
+ fs.writeFileSync(f, value + '\n' + (rest || '# PMOS repository profile (D68): pm-artifacts | product-code\n'));
77
+ }
78
+ /** Turn a refresh source on for this product with a morning read, unless the PM already has it on. → true when this call turned it on. */
79
+ function turnOn(refresh, id, ws, schedule) {
80
+ if (!refresh) return false;
81
+ let cur = null; try { const set = refresh.settings(); cur = set && set.sources && set.sources[id]; } catch {}
82
+ if (cur && cur.on) return false;
83
+ const r = refresh.setSource(id, schedule ? { on: true, schedule } : { on: true });
84
+ if (r && r.ok && refresh.ensureOn) refresh.ensureOn(id, ws); // ensureOn names the product as the source's workspace when none is set
85
+ return !!(r && r.ok);
86
+ }
87
+
88
+ /** The refresh settings in the runtime folder (lib/refresh.js, the same file lib/refresh-run.js ticks on), as settle() and change() use them. */
89
+ function refreshAt(dir) {
90
+ if (!dir) return null;
91
+ const R = require('./refresh.js');
92
+ return {
93
+ settings: () => R.readSettings(dir),
94
+ setSource: (id, patch) => {
95
+ const src = R.BY_ID.get(id); if (!src) return { ok: false };
96
+ const set = R.readSettings(dir); const cur = set.sources[id]; if (!cur) return { ok: false };
97
+ if (patch.schedule) { const n = R.normalize(src, patch.schedule); if (!n.ok) return n; cur.s = n.s; }
98
+ if (typeof patch.on === 'boolean') cur.on = patch.on;
99
+ set.migrated = true; R.writeSettings(dir, set);
100
+ return { ok: true };
101
+ },
102
+ ensureOn: (id, ws) => { const set = R.readSettings(dir); const cur = set.sources[id]; if (cur && ws && !cur.ws) { cur.ws = ws; R.writeSettings(dir, set); } },
103
+ };
104
+ }
105
+
106
+ function launcherOps(env = process.env) {
107
+ if (!profileDefault.enabledFor(env) || String(env.PMOS_AUTO_LAUNCHER || '') === '0') return null;
108
+ const L = require('./launcher-cli.js');
109
+ const quiet = () => {};
110
+ return {
111
+ installed: () => { try { return L.run(['launcher', 'status'], { say: quiet }) === 0; } catch { return false; } },
112
+ on: (root) => { try { return L.run(['launcher', 'on', root], { say: quiet, cwd: root }) === 0; } catch { return false; } },
113
+ off: () => { try { return L.run(['launcher', 'off'], { say: quiet }) === 0; } catch { return false; } },
114
+ };
115
+ }
116
+
117
+ function settle({ root, ws, sources = [], fresh = true, refresh = null, launcher = launcherOps(), now = () => new Date() }) {
118
+ const { pm, ctx } = contextOf(root);
119
+ const f = (ctx && ctx.fields) || {};
120
+ const name = (ctx && ctx.name) || path.basename(root);
121
+ const lines = [];
122
+ const did = {};
123
+ // profile (D68)
124
+ const code = profileDefault.looksLikeCode(root);
125
+ let prof = readProfile(root);
126
+ if (code.code && prof !== 'product-code' && (fresh || !prof)) { // a profile the PM already had is theirs (fresh: setup installed the kit now)
127
+ writeProfile(root, 'product-code'); prof = 'product-code'; did.profile = 'code'; }
128
+ else if (!prof) { writeProfile(root, 'pm-artifacts'); prof = 'pm-artifacts'; }
129
+ lines.push(prof === 'product-code'
130
+ ? { key: 'profile', text: 'A product with code: records are saved on the default branch, and each initiative gets its own branch.', source: code.code ? code.why : PROFILE, change: { label: 'it is documents only', to: 'docs' } }
131
+ : { key: 'profile', text: 'A document product: PMOS saves its records for you.', source: 'no code in the folder', change: { label: 'it has code', to: 'code' } });
132
+ // workflow (D99)
133
+ const hint = workflowHint(f.purpose && f.purpose.value);
134
+ lines.push({ key: 'workflow', text: 'Workflow: Product feature, for every new initiative.', source: 'the default workflow',
135
+ hint: hint ? { word: hint.word, say: `Draft a workflow for ${hint.word} work, as a copy of Product feature, from the purpose of ${name}` } : null,
136
+ change: { label: 'another workflow', to: 'settings' } });
137
+ // jira
138
+ if (ctx && ctx.jira) {
139
+ const proven = answered(sources, /atlassian|jira/i);
140
+ if (proven) { did.mywork = turnOn(refresh, 'mywork', ws, MORNING); did.jira = turnOn(refresh, 'jira', ws, MORNING); }
141
+ lines.push(proven
142
+ ? { key: 'jira', text: `Jira ${ctx.jira.key} linked: read-only, every weekday morning. Your tickets show in My work.`, source: ctx.jira.url || 'the setup search', change: did.mywork || did.jira ? { label: 'turn off', to: 'off' } : null }
143
+ : { key: 'jira', text: `Jira ${ctx.jira.key} is noted in the project context. It is not read until Jira answers a search.`, source: 'you', change: null });
144
+ }
145
+ // calendar
146
+ if (f.weekly && f.weekly.source === 'finding' && /calendar/i.test(f.weekly.source_label || '') && answered(sources, /calendar/i)) {
147
+ did.calendar = turnOn(refresh, 'calendar', ws, null);
148
+ lines.push({ key: 'calendar', text: `Calendar read: ${f.weekly.value} is your weekly.`, source: f.weekly.source_label, change: did.calendar ? { label: 'turn off', to: 'off' } : null });
149
+ }
150
+ // about
151
+ if (f.purpose && f.purpose.value) lines.push({ key: 'about', text: `About: “${clip(f.purpose.value, 200)}”`, source: f.purpose.source === 'finding' ? f.purpose.source_label : f.purpose.source === 'folder' ? 'the folder README' : 'you', change: { label: 'edit', to: 'context' } });
152
+ // goal
153
+ if (pm && (f.purpose || (ctx && ctx.goal))) {
154
+ const file = path.join(pm.planning, 'okrs', 'okrs-draft.md');
155
+ // a product that already declares a key result keeps it: no draft beside it (B2 writes the kept outcome as okrs-<quarter>.md)
156
+ const has = require('./okr-goals.js').read(root);
157
+ if (!fs.existsSync(file) && !(has.ok && has.objectives.length)) {
158
+ const text = goalFile(name, ctx && ctx.goal, f.purpose && f.purpose.value, now().toISOString().slice(0, 10));
159
+ fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text);
160
+ did.goal = { file: path.relative(root, file), sha: sha(text) };
161
+ }
162
+ if (did.goal) lines.push({ key: 'goal', text: 'Goal file: one key result drafted from the purpose, marked draft. It counts only once you keep it in Intelligence.', source: did.goal.file, change: { label: 'remove', to: 'drop' } });
163
+ }
164
+ // launcher
165
+ if (launcher) {
166
+ const had = launcher.installed();
167
+ if (!had) did.launcher = launcher.on(root);
168
+ if (had || did.launcher) lines.push({ key: 'launcher', text: 'PMOS opens from Applications and starts at login.', source: had ? 'already set up' : 'pmos launcher on', change: { label: 'turn off', to: 'off' } });
169
+ }
170
+ return { at: now().toISOString(), root, ws, lines, did };
171
+ }
172
+
173
+ /** One press on a line's change. Undo only what setup itself did; anything else is a link the page follows (settings, context). */
174
+ function change(state, key, { refresh = null, launcher = launcherOps() } = {}) {
175
+ if (!state || !Array.isArray(state.lines)) return { ok: false, error: 'Nothing was set up yet.' };
176
+ const line = state.lines.find((l) => l.key === key);
177
+ if (!line || !line.change) return { ok: false, error: 'That line has nothing to change.' };
178
+ const root = state.root, did = { ...(state.did || {}) };
179
+ let next = { ...line };
180
+ if (key === 'profile') {
181
+ const to = line.change.to === 'code' ? 'product-code' : 'pm-artifacts';
182
+ writeProfile(root, to);
183
+ next = to === 'product-code'
184
+ ? { key, text: 'A product with code: records are saved on the default branch, and each initiative gets its own branch.', source: 'you', change: { label: 'it is documents only', to: 'docs' } }
185
+ : { key, text: 'A document product: PMOS saves its records for you.', source: 'you', change: { label: 'it has code', to: 'code' } };
186
+ } else if (key === 'jira' || key === 'calendar') {
187
+ const ids = key === 'jira' ? ['mywork', 'jira'] : ['calendar'];
188
+ for (const id of ids) if (did[id] && refresh) { refresh.setSource(id, { on: false }); did[id] = false; }
189
+ next = { key, text: key === 'jira' ? `Jira is not read for this product. Turn it on any time in Settings, Sources.` : 'The calendar is not read. Turn it on any time in Settings, Sources.', source: 'you', change: null };
190
+ } else if (key === 'goal') {
191
+ const f = did.goal && path.join(root, did.goal.file);
192
+ let text = null; try { text = fs.readFileSync(f, 'utf8'); } catch {}
193
+ if (text != null && sha(text) !== did.goal.sha) return { ok: false, error: 'The goal file was changed after setup, so PMOS leaves it. Remove it yourself if you want.' };
194
+ if (text != null) fs.unlinkSync(f);
195
+ did.goal = null;
196
+ next = { key, text: 'No goal file. Say a goal in the chat any time, and I draft it again.', source: 'you', change: null };
197
+ } else if (key === 'launcher') {
198
+ if (launcher) launcher.off();
199
+ did.launcher = false;
200
+ next = { key, text: 'PMOS does not start at login. Run `pmos launcher on` to add it again.', source: 'you', change: null };
201
+ } else return { ok: true, state, follow: line.change.to };
202
+ return { ok: true, state: { ...state, did, lines: state.lines.map((l) => (l.key === key ? next : l)) } };
203
+ }
204
+
205
+ module.exports = { settle, change, workflowHint, goalFile, launcherOps, refreshAt, GOAL_FILE, MORNING };
@@ -15,6 +15,18 @@ function save(root, id, patch) {
15
15
  finally { try { fs.unlinkSync(tmp); } catch {} }
16
16
  return next;
17
17
  }
18
+ /** The moments of gradual exposure (lib/moments.js): { moments?, places? } replace those two fields. It keeps `started` as it was, so a record
19
+ * written only for moments never reads as a setup that ran. */
20
+ function saveMoments(root, id, patch) {
21
+ const f = file(root, id); fs.mkdirSync(path.dirname(f), { recursive: true });
22
+ const cur = read(root, id), next = { ...cur, updated_at: new Date().toISOString() };
23
+ if (patch.moments) next.moments = patch.moments;
24
+ if (patch.places) next.places = patch.places;
25
+ const tmp = f + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
26
+ try { fs.writeFileSync(tmp, JSON.stringify(next) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, f); }
27
+ finally { try { fs.unlinkSync(tmp); } catch {} }
28
+ return next;
29
+ }
18
30
  function hasWork(ctx, w) {
19
31
  const dirs = [path.join(w.root, 'planning', 'initiatives')];
20
32
  try { const state = ctx.stateDirFor && ctx.stateDirFor(w); if (state) dirs.push(path.join(state, 'initiatives')); } catch {}
@@ -33,4 +45,4 @@ function task(ctx, w, body) {
33
45
  pmItems.appendEvent(path.join(ctx.runtimeDir, 'pm-items', 'items.jsonl'), ev, new Date());
34
46
  return { ...r.body.item, id, title: body.title.trim(), due, flagged: !due };
35
47
  }
36
- module.exports = { read, save, hasWork, task, STEPS };
48
+ module.exports = { read, save, saveMoments, hasWork, task, STEPS };
@@ -11,8 +11,10 @@
11
11
  * A run that started is counted toward the day's cap (gate.granted). The count of running runs adds the Driver's runs to the runs
12
12
  * started here that have not ended, so the cap covers all orchestrator work together. It also adds `pmWorking`: the PM's chat-panel
13
13
  * sessions (a /goal included) that are working now. Those are never held or counted toward the daily cap, but they use the same
14
- * machine and the same usage, so background work yields to them. `own()` is the runs started here alone, for the Driver's own ask. */
15
- function wrap({ gate, spawn, driverRunning = () => 0, pmWorking = () => 0 }) {
14
+ * machine and the same usage, so background work yields to them. `own()` is the runs started here alone, for the Driver's own ask.
15
+ * `holder(initiative)` (lib/bg-queue.js, one background run per initiative): a start for an initiative that another live background
16
+ * run holds is held the same way, and tried again on the next tick. */
17
+ function wrap({ gate, spawn, driverRunning = () => 0, pmWorking = () => 0, holder = null }) {
16
18
  const live = new Set();
17
19
  const prune = () => { for (const s of [...live]) if (!s || s.state === 'exited') live.delete(s); };
18
20
  const n = (f) => { try { return Number(f()) || 0; } catch { return 0; } };
@@ -25,6 +27,11 @@ function wrap({ gate, spawn, driverRunning = () => 0, pmWorking = () => 0 }) {
25
27
  let g = { allowed: true };
26
28
  try { g = gate.mayStart({ running: running() }) || g; } catch { /* a broken gate must not stop the PM's schedules */ }
27
29
  if (!g.allowed) return held(g.reason || 'it is not time to start more runs');
30
+ if (holder && spec && spec.initiative) {
31
+ let h = null;
32
+ try { h = holder(spec.initiative); } catch { h = null; }
33
+ if (h) return held(`another background run (${h.name || h.origin}) is working on ${spec.initiative}`);
34
+ }
28
35
  const s = spawn(spec);
29
36
  if (s && s.state !== 'exited') { live.add(s); try { gate.granted(); } catch { /* a counter */ } }
30
37
  return s;