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
@@ -1,10 +1,14 @@
1
1
  /* pm/cozy-onboard.js — set up a project as one conversation in the Home chat (initiative claude-onboarding-build, direction B on the
2
2
  * onboarding design canvas, In development; its link is in planning/prd/claude-onboarding-build-prd.md).
3
3
  *
4
- * One page: it draws in the Home chat's own place (same hm-a / hm-u turns, ut-card / ut-btn / hm-chip controls and the normal composer)
5
- * and leaves the list where it is. Every step is a Claude turn with inline controls; the composer answers the project question.
6
- * Order, enforced by the server: connect Claude → test that exact login → the project → the folder → the sources it may read →
7
- * review → set up or reuse → the project open in My Work. Every press is the PM's (pmPost). Text from connectors is escaped. */
4
+ * One page: it draws in the Home chat's own place (same hm-a / hm-u turns and the normal composer) and leaves the list where it is.
5
+ * Onboarding build B1 (frames ObA1Setup, ObA3Fundamentals): one line about the login, one question, two ✦, one fundamentals card.
6
+ * 1. The login is checked silently on first open (claude-readiness.js). A pass is one line; only a failure asks, as one ✦ "Sign in".
7
+ * 2. The first question is the product. The composer is open from the first screen; an answer typed before the check passes waits for it.
8
+ * 3. ✦ one proposal for the folder, the read-only search and what needs a sign-in. ✓ runs it; ✕ asks what to change, in the composer.
9
+ * 4. The findings as up to three source tiles, then ✦ "Set up PMOS in <folder> with this context". A missing line says so and has edit.
10
+ * 5. After init: the fundamentals card (lib/onboard-fundamentals.js), each line with its source and one change; then ✦ the first initiative.
11
+ * The calls to action are ✓ and ✕ only. Order is enforced by the server. Every press is the PM's (pmPost). Text from connectors is escaped. */
8
12
  import { pmGet, pmPost } from './http.js';
9
13
  import { adler } from './cozy-adler.js';
10
14
  import { signInTerminal } from './cozy-claude-setup.js';
@@ -12,7 +16,26 @@ import { signInTerminal } from './cozy-claude-setup.js';
12
16
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
13
17
  const CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
14
18
  const UP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
15
- const FIELD_LABELS = [['name', 'Name'], ['purpose', 'Purpose'], ['team', 'Team'], ['owner', 'Owner'], ['timeline', 'Timeline'], ['goals', 'Goals and metrics']];
19
+ const FIELD_LABELS = [['name', 'Name'], ['purpose', 'Purpose'], ['team', 'Team'], ['timeline', 'Timeline'], ['weekly', 'Weekly'], ['owner', 'Owner'], ['goals', 'Goals']];
20
+ const YES = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
21
+ const NO = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
22
+ const PEN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16v4z"/></svg>';
23
+
24
+ /** What the PM asks to change in the first ✦, in words: a folder path, sources to leave out, or no search. PURE. */
25
+ export function readChange(text, labels = []) {
26
+ const t = String(text || '').trim();
27
+ const folder = (/(?:^|\s)((?:~|\/|[A-Za-z]:\\)[^\s,;]+)/.exec(t) || [])[1] || '';
28
+ const skip = /\b(no|skip|don'?t|do not)\s+(search|look)/i.test(t) || /^skip\b/i.test(t);
29
+ const out = labels.filter((l) => new RegExp('\\b' + l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i').test(t));
30
+ return { folder, skip, leaveOut: out };
31
+ }
32
+ /** A line about the context, in words: "team is Payments", "owner: Dana", "the weekly is Tue 10:00". Otherwise the words are the purpose. PURE. */
33
+ export function readField(text) {
34
+ const t = String(text || '').trim();
35
+ const m = /^(?:the\s+)?(name|purpose|team|owner|timeline|weekly|goals?)\s*(?:is|are|:|=)\s*(.+)$/i.exec(t);
36
+ if (m) return { [m[1].toLowerCase().replace(/^goal$/, 'goals')]: m[2].trim() };
37
+ return t ? { purpose: t } : {};
38
+ }
16
39
 
17
40
  /** The words of the project answer: the name is the first clause; a Jira key, a team and a folder path are picked up when written plainly. */
18
41
  export function readAnswer(text) {
@@ -31,95 +54,124 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
31
54
  sec.innerHTML = '<div class="hm-log ob-log" id="obLog" role="log" aria-live="polite"></div><div class="hm-foot" id="obFoot"></div>';
32
55
  host.after(sec); host.hidden = true;
33
56
  const log = sec.querySelector('#obLog'), foot = sec.querySelector('#obFoot');
34
- const S = { data: null, busy: '', note: '', profile: null, said: '', session: null, folderInput: false, picked: null, stopTerm: null };
57
+ const S = { data: null, busy: '', note: '', profile: null, said: '', session: null, folderInput: false, stopTerm: null,
58
+ autoChecked: false, chooser: false, queued: '', asking: '', excluded: new Set(), editing: new Set(), changes: [] };
35
59
  const post = async (verb, body = {}) => { const r = await pmPost('/api/onboard/' + verb, body); if (r && r.progress) S.data = { ...S.data, ...r }; return r || { ok: false, error: 'PMOS did not answer. Try again.' }; };
36
60
 
37
- async function load() { const r = await pmGet('/api/onboard/state'); if (r && r.ok) S.data = r; draw(); }
61
+ async function load() { const r = await pmGet('/api/onboard/state'); if (r && r.ok) S.data = r; draw(); auto(); }
38
62
  const P = () => (S.data && S.data.progress) || {};
39
63
  const claudeTurn = (body, mood, latest) => `<div class="hm-a ob-turn">${latest ? `<span class="hm-av hm-adler" aria-hidden="true">${adler(mood, 36, { label: false, id: 'ob', dress: true })}</span>` : '<span class="hm-av t-C s28" aria-hidden="true">C</span>'}<div class="hm-ab"><p class="hm-who">Claude</p>${body}</div></div>`;
40
64
  const youTurn = (text) => `<div class="hm-u"><div class="hm-bub"><p>${esc(text)}</p></div><span class="hm-you">You</span></div>`;
41
65
  const btn = (act, label, cls = '', extra = '') => `<button type="button" class="ut-btn ${cls}" data-ob="${act}"${S.busy ? ' disabled' : ''}${extra}>${esc(S.busy === act ? 'Working…' : label)}</button>`;
66
+ /** ✓ and ✕, icon-only (the PM's rule). */
67
+ const yesNo = (yes, no, what) => `<div class="ob-yn"><button type="button" class="ut-ib lg solid" data-ob="${yes}" aria-label="Yes: ${esc(what)}" title="Yes"${S.busy ? ' disabled' : ''}>${S.busy === yes ? '…' : YES}</button>`
68
+ + `<button type="button" class="ut-ib lg ob-no" data-ob="${no}" aria-label="No, change something" title="Change something"${S.busy ? ' disabled' : ''}>${NO}</button></div>`;
69
+ const prop = (title, rows, foot2, yes, no, what) => `<div class="ut-card ob-prop" role="group" aria-label="${esc(what)}"><p class="ob-pt"><span class="ob-spark" aria-hidden="true">✦</span><span>${title}</span></p>`
70
+ + (rows.length ? `<ul class="ob-pl">${rows.map((r) => `<li>${r}</li>`).join('')}</ul>` : '') + (foot2 ? `<p class="ob-note">${foot2}</p>` : '') + yesNo(yes, no, what) + '</div>';
71
+ const link = (act, label, extra = '') => `<button type="button" class="ob-lk" data-ob="${act}"${extra}>${esc(label)}</button>`;
72
+ const profiles = () => ((S.data && S.data.claude) || {}).profiles || [];
42
73
 
43
- function turns() {
44
- const p = P(), c = (S.data && S.data.claude) || { profiles: [] }, out = [];
45
- // 1. connect and test
46
- // One row per login that really differs: a pinned copy of the login on this computer shows as that login, and PMOS's own
47
- // profile is the "separate login" row (bug 2026-11-03: "My usual Claude login" and "PMOS" repeated the other two rows).
48
- const profiles = c.profiles || [], ownId = c.own || '__own__';
74
+ /** The old explicit chooser, kept for "use another login": one row per login that really differs. */
75
+ function chooserTurn(p) {
76
+ const c = (S.data && S.data.claude) || { profiles: [] }, ownId = c.own || '__own__';
49
77
  const row = (id) => (id === '__own__' || (id && id === c.own) ? ownId : (c.alias && c.alias[id]) || id);
50
- const pick = row(S.profile || p.profile || c.selected || (profiles[0] && profiles[0].id));
78
+ const pick = row(S.profile || p.profile || c.selected || (profiles()[0] && profiles()[0].id));
79
+ const opt = (id, label, note) => `<label class="ob-opt${id === pick ? ' on' : ''}"><input type="radio" name="ob-login" value="${esc(id)}"${id === pick ? ' checked' : ''}><span><b>${esc(label)}</b>${note ? `<em>${esc(note)}</em>` : ''}</span></label>`;
80
+ const opts = profiles().map((x) => opt(x.id, x.id === '__default__' ? 'The Claude login on this computer' : x.label, x.id === '__default__' ? 'Whatever account Claude Code uses in your usual terminal' : '')).join('')
81
+ + opt(ownId, 'A separate login just for PMOS', 'Keeps PMOS on its own account. You sign in once.');
82
+ const failed = p.checked && p.checked.state && p.checked.state !== 'ready' && S.busy !== 'check' && row(p.profile) === pick;
83
+ return (failed ? `<div class="ob-bad" role="alert"><b>${esc(p.checked.message || 'This login did not pass the check.')}</b><span>I won’t switch to a different account on my own. Sign this one in, or pick another login.</span></div>` : '')
84
+ + `<p>Which Claude login should PMOS use? I test exactly that one.</p><div class="ut-card ob-card" role="radiogroup" aria-label="Claude login">${opts}`
85
+ + `<div class="ob-row">${btn('check', 'Test this login', 'pri')}${btn('login', 'Sign in to it', '')}</div>`
86
+ + `<p class="ob-note">The test sends one short message to Claude with no tools. It uses a little of this account’s usage.</p></div>`;
87
+ }
88
+ const loginLabel = (p) => { const c = (S.data && S.data.claude) || {}; const id = p.profile; if (!id || id === '__default__' || (c.alias && c.alias[id] === '__default__')) return 'the login on this computer'; if (id === c.own) return 'PMOS’s own login'; return ((profiles().find((x) => x.id === id) || {}).label) || id; };
89
+
90
+ function turns() {
91
+ const p = P(), out = [];
92
+ // 1. the login, checked silently
51
93
  if (!p.verified) {
52
- const failed = p.checked && p.checked.state && p.checked.state !== 'ready' && row(p.profile) === pick;
53
- const opt = (id, label, note) => `<label class="ob-opt${id === pick ? ' on' : ''}"><input type="radio" name="ob-login" value="${esc(id)}"${id === pick ? ' checked' : ''}><span><b>${esc(label)}</b>${note ? `<em>${esc(note)}</em>` : ''}</span></label>`;
54
- const opts = profiles.map((x) => opt(x.id, x.id === '__default__' ? 'The Claude login on this computer' : x.label, x.id === '__default__' ? 'Whatever account Claude Code uses in your usual terminal' : '')).join('')
55
- + opt(ownId, 'A separate login just for PMOS', 'Keeps PMOS on its own account. You sign in once.');
56
- out.push({ mood: failed ? 'honest' : 'needs-you', body: `<p>${failed ? '' : 'Let’s set up a product or project. First, which Claude login should PMOS use? I’ll run one short check on exactly that login.'}</p>`
57
- + (failed ? `<div class="ob-bad" role="alert"><b>${esc(p.checked.message || (p.checked.state === 'signed_out' ? 'This login needs signing in again.' : 'This login did not pass the check.'))}</b><span>I won’t switch to a different account on my own. Sign this one in, or pick another login.</span></div>` : '')
58
- + `<div class="ut-card ob-card" role="radiogroup" aria-label="Claude login">${opts}`
59
- + `<div class="ob-row">${btn('check', 'Test this login', 'pri')}${btn('login', 'Sign in to it', '')}</div>`
60
- + `<p class="ob-note">The test sends one short message to Claude with no tools. It uses a little of this account’s usage.</p>`
61
- + (S.session ? '<p class="ob-note">Finish signing in in the browser window Claude opened. If it asks for a code, paste it below. I test the login as soon as you are done.</p><div class="cs-term ob-term" aria-label="Claude sign-in terminal"></div>' : '')
62
- + `</div>` });
94
+ const failed = p.checked && p.checked.state && p.checked.state !== 'ready' && S.busy !== 'check' && S.autoChecked;
95
+ if (S.chooser) out.push({ mood: 'needs-you', body: chooserTurn(p) + (S.session ? signInNote() : '') });
96
+ else if (!failed) out.push({ mood: 'thinking', body: '<p class="ob-sub">Checking Claude Code on this computer…</p>' });
97
+ else out.push({ mood: 'honest', body: prop('Sign in to Claude Code', [esc(p.checked.message || 'This login did not pass the check.') + ' I won’t switch to a different account on my own.'],
98
+ `${link('chooser', 'Use another login')}`, 'login', 'chooser', 'Sign in to Claude Code') + (S.session ? signInNote() : '') });
99
+ } else {
100
+ out.push({ mood: 'handled', body: `<div class="ob-good">${CHECK}<b>Claude Code is signed in on this computer, and I just tested it</b><span>Using ${esc(loginLabel(p))} · ${link('settings-claude', 'change in Settings')}</span></div>` });
101
+ }
102
+ // 2. the product question, from the first screen
103
+ if (!p.project) {
104
+ out.push({ mood: 'needs-you', body: '<p>What product or project is this for? A name is enough.</p><p class="ob-sub">The team, a Jira key or a link help me find the right sources.</p>' });
105
+ if (S.queued) out.push({ you: S.queued }, { mood: 'thinking', body: '<p class="ob-sub">I start as soon as the Claude check passes.</p>' });
63
106
  return out;
64
107
  }
65
- const label = row(p.profile) === ownId ? 'A separate login just for PMOS' : (profiles.find((x) => x.id === row(p.profile)) || {}).label || p.profile;
66
- out.push({ mood: 'handled', body: `<div class="ob-good">${CHECK}<b>Login works · ${esc(label)}</b><span>Tested ${p.checked && p.checked.at ? new Date(p.checked.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'just now'} · one test message, no tools</span></div>`
67
- + (p.project ? '' : `<p>What product or project is this for? Its name is enough to start.</p><p class="ob-sub">If you have them, add the team, a Jira key, a link or the folder where it lives. They help me find the right sources, not a same-name one.</p><div class="ob-row"><button type="button" class="hm-chip loose" data-ob="relogin">Use another login</button></div>`) });
68
- if (!p.project) return out;
69
- // 2. the project, the folder, the sources
70
108
  out.push({ you: S.said || p.project.description || p.project.name });
71
- const f = p.folder;
72
- const discovered = p.discovered && p.discovered.sources;
73
- let body = `<p><b>${esc(p.project.name)}</b>, got it. Where should its PMOS project live?</p><div class="ut-card ob-card" role="radiogroup" aria-label="Project folder">`;
74
- const ex = f && f.mode === 'existing';
75
- if (ex) {
76
- const fa = f.facts || {};
77
- body += `<label class="ob-opt on"><input type="radio" name="ob-folder" value="existing" checked><span><b>Use the existing folder <code>${esc(f.root)}</code></b><em>${fa.pmos ? 'PMOS is already here. I’ll open it as it is; nothing is reinstalled.' : (fa.git ? 'Git repository, no PMOS yet. ' : 'No PMOS yet. ') + 'I add only what’s missing; files and history stay as they are.'}${fa.inside_other_git ? ' It is inside the Git repository ' + esc(fa.git_root) + '.' : ''}</em></span></label>`;
78
- }
79
- const proposed = f && f.mode === 'new' ? f.root : null;
80
- body += `<label class="ob-opt${!ex ? ' on' : ''}"><input type="radio" name="ob-folder" value="new"${!ex ? ' checked' : ''}><span><b>Make a new folder${proposed ? ` <code>${esc(proposed)}</code>` : ''}</b><em>${proposed ? 'Empty folder with Git and PMOS. Nothing is created until you confirm.' : 'I’ll propose a place for it.'}${f && f.parent_in_git ? ' Its parent folder is inside a Git repository.' : ''}</em></span></label>`;
81
- body += `<div class="ob-row">${btn('pick-existing', 'Choose an existing folder…', 'sm')}${btn('pick-parent', 'Put the new folder somewhere else…', 'sm ghost')}</div>`;
82
- if (S.folderInput) body += `<form class="ob-path" data-ob-form="path"><label class="ut-field"><span>Folder path</span><input name="path" autocomplete="off" placeholder="${S.data.platform === 'win32' ? 'C:\\Users\\you\\Projects\\checkout' : '~/code/checkout'}"></label><button type="submit" class="ut-btn sm">Use this folder</button></form>`;
83
- body += '</div>';
84
- if (f && !p.found) {
85
- if (!discovered) body += `<p>Next I’ll list what this login can read. That doesn’t use Claude.</p><div class="ob-row">${btn('discover', 'Show what I can read', 'pri')}</div>`;
86
- else {
87
- const rows = discovered.map((s) => `<label class="ob-opt${s.status !== 'connected' ? ' off' : ''}"><input type="checkbox" name="ob-src" value="${esc(s.id)}"${s.status === 'connected' ? ' checked' : ' disabled'}><span><b>${esc(s.label)}</b><em>${s.status === 'connected' ? 'Search for “' + esc(p.project.name) + '”' + (p.project.jira ? ' and ' + esc(p.project.jira) : '') : s.status === 'needs_auth' ? 'Needs signing in inside Claude first' : 'Not reachable right now'}</em></span></label>`).join('');
88
- const local = ex ? `<label class="ob-opt"><input type="checkbox" name="ob-local" checked><span><b>The folder</b><em>README and Git history in ${esc(f.root)}</em></span></label>` : '';
89
- body += `<p>I can look for existing context with <b>${esc(label)}</b>. Choose what I may read:</p><div class="ut-card ob-card">${rows || '<p class="ob-sub">This login has no connected tools.</p>'}${local}`
90
- + `<div class="ob-row">${btn('search', 'Search, read-only', 'pri')}${btn('skip', 'Skip, use my description', 'ghost')}</div><p class="ob-note">Read-only. I won’t post, comment or change anything. Searching uses some of this account’s usage.</p></div>`;
91
- }
109
+ for (const c of S.changes.filter((x) => x.at === 'plan')) out.push({ you: c.text });
110
+ // 3. one ✦: folder + sources + search
111
+ const f = p.folder, disc = p.discovered && p.discovered.sources;
112
+ if (!p.found && !p.reviewed) {
113
+ if (!f || !disc) { out.push({ mood: S.busy ? 'thinking' : 'honest', body: S.busy ? '<p class="ob-sub">Looking at where it can live and what this login can read…</p>' : `<p class="ob-sub">I couldn’t list what this login can read. ${link('relist', 'Try again')}</p>` }); return out; }
114
+ const ex = f.mode === 'existing', fa = f.facts || {};
115
+ const search = disc.filter((s) => s.status === 'connected' && !S.excluded.has(s.id));
116
+ const sign = disc.filter((s) => s.status === 'needs_auth' && !S.excluded.has(s.id));
117
+ const rows = [`<b>Folder</b> <code>${esc(f.root)}</code> · ${ex ? (fa.pmos ? 'PMOS is already here; I reuse it' : 'I add only what is missing; files and history stay') : 'a new folder with Git and PMOS'} · ${link('pick-existing', 'Use another folder', ' aria-label="Choose an existing folder"')}`];
118
+ if (search.length || ex) rows.push(`<b>Search, read-only</b> ${esc([...search.map((s) => s.label), ...(ex ? ['the folder README and Git history'] : [])].join(', '))} for “${esc(p.project.name)}”${p.project.jira ? ' and ' + esc(p.project.jira) : ''}`);
119
+ else rows.push('<b>Search</b> no connected tool to search. I use what you wrote.');
120
+ for (const s of sign) rows.push(`${esc(s.label)} needs signing in inside Claude; I’ll leave it out · <a href="https://claude.ai/settings/connectors" target="_blank" rel="noopener noreferrer">sign in</a>`);
121
+ if (S.excluded.size) rows.push(`<span class="ob-sub">Left out: ${esc(disc.filter((s) => S.excluded.has(s.id)).map((s) => s.label).join(', '))}</span>`);
122
+ let body = `<p><b>${esc(p.project.name)}</b>, got it.</p>` + prop(`Set up ${esc(p.project.name)} here, and look for what already exists`, rows,
123
+ search.length ? 'Read-only: I won’t post, comment or change anything. The search uses a little of this account’s usage.' : 'Nothing is created until you say yes.', 'plan-yes', 'plan-no', 'Set up here and search');
124
+ if (S.folderInput) body += `<form class="ob-path" data-ob-form="path"><label class="ut-field"><span>Folder path</span><input name="path" autocomplete="off" placeholder="${S.data.platform === 'win32' ? 'C:\\Users\\you\\Projects\\checkout' : '~/code/checkout'}"></label></form>`;
125
+ out.push({ mood: 'needs-you', body });
126
+ if (S.asking === 'plan') out.push({ mood: 'needs-you', body: '<p>What should I change? Write a folder path, name a source to leave out, or say “no search”.</p>' });
127
+ return out;
92
128
  }
93
- out.push({ mood: f && p.found ? 'handled' : 'needs-you', body });
94
- if (!p.found && !p.reviewed) return out;
95
- // 3. review
96
- const found = p.found || { sources: [], jira: [], findings: [] };
97
- if (p.found) out.push({ you: 'Search these sources' });
98
- const rv = p.review || { fields: {} };
129
+ // 4. the findings as source tiles, then ✦ set up with this context
130
+ const found = p.found || { sources: [], jira: [], findings: [], highlights: [] };
131
+ const tiles = (found.highlights && found.highlights.length ? found.highlights : firstPerSource(found.findings)).slice(0, 3);
99
132
  let rb = '';
100
- if (found.sources.length) rb += `<div class="ob-srcs">${found.sources.map((s) => `<span class="ob-src ${s.state}">${esc(s.label)} · ${s.state === 'found' ? (s.count ? s.count + ' found' : 'found') : s.state === 'empty' ? 'nothing found' : 'couldn’t read'}</span>`).join('')}</div>`;
101
- const failed = found.sources.filter((s) => s.state === 'failed');
102
- if (failed.length) rb += failed.map((s) => `<p class="ob-sub">${esc(s.label)}: ${esc(s.error || 'It could not be read.')} I used nothing from it.</p>`).join('') + `<div class="ob-row">${btn('retry', 'Retry', 'sm')}</div>`;
133
+ if (p.found) {
134
+ rb += tiles.length ? `<p>I found ${tiles.length === 1 ? 'one thing' : ['', '', 'two', 'three'][tiles.length] + ' things'} about <b>${esc(p.project.name)}</b>.</p><div class="ob-tiles">${tiles.map((t) => `<div class="ut-card ob-tile"><span class="ob-ts">${esc(t.source_label)}</span><b>${t.url ? `<a href="${esc(t.url)}" target="_blank" rel="noopener noreferrer">${esc(t.headline)}</a>` : esc(t.headline)}</b>${t.detail ? `<span>${esc(t.detail)}</span>` : ''}</div>`).join('')}</div>`
135
+ : `<p>I didn’t find anything about <b>${esc(p.project.name)}</b>. Tell me in a sentence what it is for, or leave it blank.</p>`;
136
+ const failed = found.sources.filter((s) => s.state === 'failed');
137
+ if (failed.length) rb += `<p class="ob-sub">${failed.map((s) => `${esc(s.label)}: ${esc(s.error || 'It could not be read.')}`).join(' ')} I used nothing from ${failed.length === 1 ? 'it' : 'them'}. ${link('retry', 'Try again')}</p>`;
138
+ }
139
+ const rv0 = p.review || { fields: {} }, rv = S.jira ? { ...rv0, jira: S.jira } : rv0; // a choice made on the page survives a redraw
103
140
  if (found.jira && found.jira.length > 1) {
104
141
  rb += `<p><b>${found.jira.length} Jira projects could be this one.</b> Which is it?</p><div class="ut-card ob-card" role="radiogroup" aria-label="Jira project">${found.jira.map((j) => `<label class="ob-opt${rv.jira === j.key ? ' on' : ''}"><input type="radio" name="ob-jira" value="${esc(j.key)}"${rv.jira === j.key ? ' checked' : ''}><span><b>${esc(j.key)}${j.name ? ' · ' + esc(j.name) : ''}</b></span></label>`).join('')}<label class="ob-opt${rv.jira === 'none' ? ' on' : ''}"><input type="radio" name="ob-jira" value="none"${rv.jira === 'none' ? ' checked' : ''}><span>Neither. Don’t link Jira</span></label></div>`;
105
142
  }
106
- if (!found.findings.length && p.found && !failed.length) rb += '<p>I didn’t find much. Tell me in a sentence or two what this project is for, or fill in the fields below.</p>';
107
143
  const fields = rv.fields || {};
108
- rb += `<form class="ut-card ob-card ob-ctx" data-ob-form="review"><p><b>Project context</b> <span class="ob-sub">· saved to the project when you set it up. Edit anything.</span></p>`
109
- + FIELD_LABELS.map(([k, l]) => { const v = fields[k]; const tag = !v ? '<span class="ob-tag miss">Missing</span>' : v.source === 'finding' ? `<span class="ob-tag">${v.url ? `<a href="${esc(v.url)}" target="_blank" rel="noopener noreferrer">${esc(v.source_label)}</a>` : esc(v.source_label)}</span>` : `<span class="ob-tag you">${v.source === 'folder' ? 'Folder' : 'You'}</span>`;
110
- return `<label class="ob-fld"><b>${l}</b><input name="${k}" value="${esc(v ? v.value : '')}" placeholder="${!v ? 'Not found. Add it or leave blank' : ''}">${tag}</label>`; }).join('')
111
- + (fields.code ? `<p class="ob-sub">Code: ${esc(fields.code.value)}</p>` : '')
112
- + `</form>`;
113
144
  const where = f && f.root ? f.root : 'the new folder';
114
- const jiraNote = rv.jira && rv.jira !== 'none' ? ` and links Jira ${esc(rv.jira)}` : '';
115
- rb += `<div class="ob-row">${btn('init', (f && f.facts && f.facts.pmos ? 'Open PMOS in ' : 'Set up PMOS in ') + where, 'pri')}${p.found ? btn('again', 'Search again', 'ghost') : ''}</div><p class="ob-note">${f && f.facts && f.facts.pmos ? 'Reuses the PMOS already there' : 'Adds PMOS to the folder'}${jiraNote}. No tasks, goals or schedules are created, and nothing changes in your tools.</p>`;
145
+ const jk = rv.jira && rv.jira !== 'none' ? rv.jira : (found.jira && found.jira.length === 1 ? found.jira[0].key : '');
146
+ const rows = FIELD_LABELS.filter(([k]) => k !== 'name').map(([k, l]) => {
147
+ const v = fields[k], editing = S.editing.has(k) || S.editing.has('all');
148
+ const src = !v ? '' : v.source === 'finding' ? (v.url ? `<a href="${esc(v.url)}" target="_blank" rel="noopener noreferrer">${esc(v.source_label)}</a>` : esc(v.source_label)) : v.source === 'folder' ? 'the folder' : 'you';
149
+ if (editing) return `<label class="ob-fld"><b>${l}</b><input name="${k}" value="${esc(v ? v.value : '')}" placeholder="Leave blank if not known"></label>`;
150
+ return `<span class="ob-fl"><b>${l}</b>${v ? `<span>${esc(v.value)}</span><em>${src}</em>` : '<span class="ob-miss">Not found. Tell me, or leave blank</span>'}<button type="button" class="ut-ib ob-pen" data-ob-edit="${k}" aria-label="Edit ${esc(l.toLowerCase())}" title="Edit">${PEN}</button></span>`;
151
+ });
152
+ rb += `<form data-ob-form="review" class="ob-rv">` + prop(`Set up PMOS in <code>${esc(where)}</code> with this context${jk ? ', and link Jira ' + esc(jk) : ''}`, rows,
153
+ 'Nothing changes in your tools, and no tasks are created. Next I settle the basics, and you can change each one.', 'init', 'ctx-no', 'Set up PMOS with this context') + '</form>';
116
154
  if (p.init && !p.init.ok) {
117
155
  const steps = p.init.steps || {};
118
- rb += `<div class="ob-bad" role="alert"><b>Setup stopped at “${esc(p.init.step || 'a step')}”.</b><span>${esc(p.init.error || '')} Nothing you had was removed. ${Object.keys(steps).filter((k) => steps[k].state === 'done').length} steps are done; I’ll only do the rest.</span></div><div class="ob-row">${btn('init', 'Finish setup', 'pri')}</div>`;
156
+ rb += `<div class="ob-bad" role="alert"><b>Setup stopped at “${esc(p.init.step || 'a step')}”.</b><span>${esc(p.init.error || '')} Nothing you had was removed. ${Object.keys(steps).filter((k) => steps[k].state === 'done').length} steps are done. Setting up again does only the rest.</span></div>`;
119
157
  }
120
158
  out.push({ mood: p.init && !p.init.ok ? 'honest' : 'needs-you', body: rb });
159
+ for (const c of S.changes.filter((x) => x.at === 'context')) out.push({ you: c.text });
160
+ if (S.asking === 'context') out.push({ mood: 'needs-you', body: '<p>What should I change? Edit a line above, or tell me, for example “owner is Dana”.</p>' });
121
161
  return out;
122
162
  }
163
+ const signInNote = () => '<p class="ob-note">Finish signing in in the browser window Claude opened. If it asks for a code, paste it below. I test the login as soon as you are done.</p><div class="cs-term ob-term" aria-label="Claude sign-in terminal"></div>';
164
+ const firstPerSource = (findings) => { const seen = new Set(), out = []; for (const x of findings || []) { if (seen.has(x.source)) continue; seen.add(x.source); out.push({ source_label: x.source_label, headline: x.title, detail: x.summary, url: x.url }); } return out; };
165
+
166
+ /** 5. After init: what PMOS settled, each with its source and one change; then ✦ the first initiative (B2 takes it from there). */
167
+ function settledTurns() {
168
+ const p = P(), d = p.done, fd = p.fundamentals || { lines: [] };
169
+ const rows = fd.lines.map((l) => { const meta = [l.source ? esc(l.source) : '', l.hint ? link('wf-new', 'draft a ' + l.hint.word + ' workflow') : '', l.change ? link('f:' + l.key, l.change.label) : ''].filter(Boolean).join(' · ');
170
+ return `<li data-key="${esc(l.key)}">${CHECK}<span><span>${esc(l.text)}</span>${meta ? `<em>${meta}</em>` : ''}</span></li>`; }).join('');
171
+ return [{ mood: 'handled', body: `<p><b>${esc(d.name)}</b> is set up in <code>${esc(d.root)}</code>. I settled the basics for you:</p>`
172
+ + `<ul class="ut-card ob-done ob-fund">${rows}</ul>${fd.error ? `<p class="ob-sub">${esc(fd.error)}</p>` : ''}`
173
+ + prop('Draft the first initiative from the purpose, with its expected outcome', [], 'Nothing is final until you keep it.', 'first-yes', 'first-no', 'Draft the first initiative') }];
174
+ }
123
175
 
124
176
  function doneTurns() {
125
177
  const d = P().done;
@@ -134,30 +186,56 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
134
186
 
135
187
  function draw() {
136
188
  if (!S.data) { log.innerHTML = `<div class="hm-empty hm-empty-ad">${adler('thinking', 'l', { id: 'obload' })}<p>Getting setup ready…</p></div>`; return; }
137
- const list = done ? doneTurns() : turns();
189
+ const p0 = P();
190
+ const list = done ? doneTurns() : p0.done && p0.fundamentals ? turns().slice(0, 1).concat(settledTurns()) : turns();
138
191
  const lastClaude = list.map((t, i) => (t.you ? -1 : i)).filter((i) => i >= 0).pop();
139
192
  // No big Adler here: the latest reply carries his face, and the list may already show him (one Adler per screen, rule 21).
140
- const intro = !done && !P().verified && !P().checked ? '<div class="hm-empty ob-hello"><p><b>Let’s set up your first project</b></p><p>It takes a few minutes. Nothing is created until you say so.</p></div>' : '';
193
+ const intro = !done && !P().project ? '<div class="hm-empty ob-hello"><p><b>Let’s set up your first project</b></p><p>It takes a few minutes. Nothing is created until you say so.</p></div>' : '';
141
194
  if (S.stopTerm) { S.stopTerm(); S.stopTerm = null; }
142
195
  log.innerHTML = intro + list.map((t, i) => (t.you ? youTurn(t.you) : claudeTurn(t.body, t.mood, i === lastClaude))).join('') + (S.note ? `<p class="ob-note ob-live" role="status">${esc(S.note)}</p>` : '');
143
196
  const term = log.querySelector('.ob-term');
144
197
  // the sign-in ends on its own once Claude has the login; a clean end runs the test at once
145
198
  if (term && S.session) signInTerminal(term, S.session, { onEnd: (code) => { S.session = null; if (!code) act('check'); else { S.note = 'The sign-in stopped before it finished. Sign in again.'; draw(); } } }).then((stop) => { S.stopTerm = stop; }).catch((e) => { S.note = e.message; S.session = null; draw(); });
146
- const p = P(), asks = !done && p.verified && !p.project;
147
- foot.innerHTML = `<form class="hm-box" data-ob-form="say"><textarea rows="1" aria-label="Message Claude" placeholder="${asks ? 'e.g. Checkout redesign, payments team, Jira PAY' : done ? 'Message Claude' : !p.verified ? 'Test the login first, then tell me about your project' : 'Message Claude'}"${!asks ? ' disabled' : ''}></textarea><div class="hm-tb"><span class="sp"></span><span class="hm-key">Enter sends · Shift+Enter for a new line</span><button type="submit" class="hm-send" aria-label="Send"${!asks ? ' disabled' : ''}>${UP}</button></div></form>`;
148
- if (asks) foot.querySelector('textarea').focus({ preventScroll: true });
199
+ // the composer is open throughout (B1): it answers the product question, the ✕ "what should I change", and after setup goes to Home
200
+ const p = P(), asks = !done && !p.project && !S.queued;
201
+ const kept = foot.querySelector('textarea'); const typed = kept ? kept.value : '';
202
+ foot.innerHTML = `<form class="hm-box" data-ob-form="say"><textarea rows="1" aria-label="Message Claude" placeholder="${asks ? 'e.g. Checkout redesign, payments team, Jira PAY' : 'Message Claude'}"${S.busy ? ' disabled' : ''}></textarea><div class="hm-tb"><span class="sp"></span><span class="hm-key">Enter sends · Shift+Enter for a new line</span><button type="submit" class="hm-send" aria-label="Send"${S.busy ? ' disabled' : ''}>${UP}</button></div></form>`;
203
+ const ta = foot.querySelector('textarea'); ta.value = typed;
204
+ if (asks || S.asking) ta.focus({ preventScroll: true });
149
205
  log.scrollTop = log.scrollHeight;
150
206
  }
151
207
 
208
+ /** Run what the page can on its own: the silent login check on first open, and the folder + connector listing after the answer. */
209
+ async function auto() {
210
+ const p = P();
211
+ if (done || S.busy) return;
212
+ if (!p.verified && !S.autoChecked && !S.chooser) { S.autoChecked = true; return act('check'); }
213
+ if (p.verified && S.queued && !p.project) { const q = S.queued; S.queued = ''; return answer(q); }
214
+ if (p.project && !p.found && !p.reviewed && (!p.folder || !p.discovered) && !S.autoListed) {
215
+ S.autoListed = true; S.busy = 'list'; draw();
216
+ if (!p.folder) await post('folder', { mode: 'new' });
217
+ if (!P().discovered) { const r = await post('discover'); if (!r.ok) S.note = r.error; }
218
+ S.busy = ''; draw();
219
+ }
220
+ }
221
+ async function answer(v) {
222
+ const a = readAnswer(v); S.said = v; S.busy = 'say'; draw();
223
+ const r = await post('project', { name: a.name, team: a.team, jira: a.jira, link: a.link, description: a.description });
224
+ if (!r.ok) S.note = r.error;
225
+ else if (a.folder) { const f = await post('folder', { mode: 'existing', root: a.folder }); if (!f.ok) S.note = 'I couldn’t use ' + a.folder + ': ' + (f.error || 'not found') + ' I propose a folder instead.'; }
226
+ S.busy = ''; S.autoListed = false; draw(); auto();
227
+ }
228
+ const go = (q = '') => location.assign('/premium.html?ws=' + encodeURIComponent(P().done.ws) + '&onboarded=1' + q);
229
+ const home = (say) => location.assign('/premium.html?ws=' + encodeURIComponent(P().done.ws) + '&say=' + encodeURIComponent(say));
230
+
152
231
  async function act(a, b) {
153
232
  const p = P();
154
- const typed = readReview(); // before any redraw, so what the PM typed, ticked or chose is never lost
155
- const ticked = [...log.querySelectorAll('input[name="ob-src"]:checked')].map((x) => x.value), localTicked = !!log.querySelector('input[name="ob-local"]:checked');
233
+ const typed = readReview(); // before any redraw, so what the PM typed or chose is never lost
156
234
  S.busy = a; S.note = ''; draw();
157
235
  try {
158
236
  if (a === 'check' || a === 'login') {
159
237
  const cl = S.data.claude || {};
160
- let profile = S.profile || p.profile || cl.selected;
238
+ let profile = S.chooser ? (S.profile || p.profile || cl.selected) : (p.profile || cl.selected || '__default__');
161
239
  if (cl.alias && cl.alias[profile]) profile = cl.alias[profile]; // a pinned copy of the usual login is tested as that login
162
240
  if (profile === '__own__') { const own = await post('own'); if (!own.ok) throw new Error(own.error || 'The PMOS login could not be made.'); profile = own.id; S.profile = profile; await load(); }
163
241
  const r = await post(a, { profile });
@@ -166,32 +244,41 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
166
244
  else if (r.signedIn) { S.session = null; S.busy = ''; return act('check'); } // already signed in: test it, no terminal
167
245
  else S.session = r.session;
168
246
  }
169
- else { S.session = null; if (!r.ok) S.note = r.message || r.error || 'The test did not pass.'; await load(); }
170
- } else if (a === 'relogin') { await post('reset'); S.profile = null; await load(); }
171
- else if (a === 'pick-existing' || a === 'pick-parent') {
172
- const mode = a === 'pick-existing' ? 'existing' : 'new';
173
- const r = await post('folder', { mode, pick: true, parent: mode === 'new' && p.folder ? p.folder.parent : undefined, root: mode === 'existing' && p.folder ? p.folder.root : undefined });
174
- if (r.unsupported) { S.folderInput = mode; S.note = r.error; } else if (!r.ok && !r.cancelled) S.note = r.error;
175
- } else if (a === 'folder-mode') {
176
- const r = await post('folder', b === 'new' ? { mode: 'new', parent: p.folder && p.folder.parent } : { mode: 'existing', root: p.folder && p.folder.root });
177
- if (!r.ok) { if (b === 'existing') S.folderInput = 'existing'; S.note = r.error; }
178
- } else if (a === 'discover') { const r = await post('discover'); if (!r.ok) S.note = r.error; }
179
- else if (a === 'search' || a === 'retry') {
180
- const ids = a === 'retry' ? ((p.found && p.found.sources) || []).filter((s) => s.id !== 'folder').map((s) => s.id) : ticked;
181
- const local = a === 'retry' ? (p.found && p.found.sources || []).some((s) => s.id === 'folder') : localTicked;
247
+ else { S.session = null; S.autoChecked = true; if (r.ok || (r.progress && r.progress.verified)) S.chooser = false; await load(); S.busy = ''; draw(); return auto(); }
248
+ } else if (a === 'chooser') { S.chooser = true; }
249
+ else if (a === 'relist') { S.autoListed = false; S.busy = ''; return auto(); }
250
+ else if (a === 'settings-claude' || a === 'settings-sources') { import('./cozy-settings.js').then((M) => M.openSettings(a === 'settings-claude' ? 'claude' : 'connectors')); }
251
+ else if (a === 'pick-existing') {
252
+ const r = await post('folder', { mode: 'existing', pick: true, root: p.folder && p.folder.mode === 'existing' ? p.folder.root : undefined });
253
+ if (r.unsupported) { S.folderInput = 'existing'; S.note = r.error; } else if (!r.ok && !r.cancelled) S.note = r.error;
254
+ } else if (a === 'plan-yes') {
255
+ const disc = (p.discovered && p.discovered.sources) || [];
256
+ const ids = disc.filter((s) => s.status === 'connected' && !S.excluded.has(s.id)).map((s) => s.id);
257
+ const local = p.folder && p.folder.mode === 'existing';
258
+ S.asking = '';
259
+ if (!ids.length && !local) { const r = await post('review', {}); if (!r.ok) S.note = r.error; }
260
+ else { S.note = 'Searching, read-only. This can take a minute or two…'; draw(); const r = await post('search', { sources: ids, local }); S.note = r.ok ? '' : r.error; }
261
+ } else if (a === 'plan-no') { S.asking = 'plan'; }
262
+ else if (a === 'retry') {
263
+ const ids = ((p.found && p.found.sources) || []).filter((s) => s.id !== 'folder').map((s) => s.id);
264
+ const local = (p.found && p.found.sources || []).some((s) => s.id === 'folder');
182
265
  S.note = 'Searching, read-only. This can take a minute or two…'; draw();
183
- const r = await post('search', { sources: ids, local });
184
- S.note = r.ok ? '' : r.error;
185
- } else if (a === 'skip') { const r = await post('review', {}); if (!r.ok) S.note = r.error; }
186
- else if (a === 'again') { await saveReview(typed); const r = await post('discover'); if (!r.ok) S.note = r.error; }
266
+ const r = await post('search', { sources: ids, local }); S.note = r.ok ? '' : r.error;
267
+ } else if (a === 'ctx-no') { S.asking = 'context'; S.editing = new Set(['all']); }
187
268
  else if (a === 'init') {
188
269
  await saveReview(typed);
189
- S.note = 'Setting up the project…'; draw();
270
+ S.asking = ''; S.note = 'Setting up the project…'; draw();
190
271
  const r = await post('init');
191
- if (r.ok && r.progress && r.progress.done) { location.assign('/premium.html?ws=' + encodeURIComponent(r.progress.done.ws) + '&onboarded=1'); return; }
192
- S.note = r.error || 'Setup did not finish.';
193
- await load();
194
- }
272
+ if (r.ok && r.progress && r.progress.done) { S.editing = new Set(); S.note = ''; S.jira = null; }
273
+ else { S.note = r.error || 'Setup did not finish.'; await load(); }
274
+ } else if (a.startsWith('f:')) {
275
+ const r = await post('fundamental', { key: a.slice(2) });
276
+ if (!r.ok) S.note = r.error;
277
+ else if (r.follow === 'settings') location.assign('/premium-workflows.html?ws=' + encodeURIComponent(p.done.ws));
278
+ else if (r.follow === 'context') { home('Walk me through the project context you saved, and change what I say'); return; }
279
+ } else if (a === 'wf-new') { location.assign('/premium-workflows.html?new=1&ws=' + encodeURIComponent(p.done.ws)); return; }
280
+ else if (a === 'first-yes') { go('&first=draft'); return; }
281
+ else if (a === 'first-no') { go('&first=none'); return; } // B2: the reveal with no draft, and nothing written
195
282
  } catch (e) { S.note = e.message || 'That did not finish. Try again.'; }
196
283
  S.busy = ''; draw();
197
284
  }
@@ -199,38 +286,57 @@ export function mountOnboard(host, { ws = '', done = false } = {}) {
199
286
  const form = log.querySelector('form[data-ob-form="review"]'); if (!form) return null;
200
287
  const fields = {}; for (const [k] of FIELD_LABELS) { const i = form.querySelector(`input[name="${k}"]`); if (i) fields[k] = i.value; }
201
288
  const j = log.querySelector('input[name="ob-jira"]:checked');
202
- return j ? { fields, jira: j.value } : { fields };
289
+ return j ? { fields, jira: j.value } : (Object.keys(fields).length ? { fields } : null);
203
290
  }
204
291
  async function saveReview(typed) { if (typed) await post('review', typed); }
205
292
 
206
293
  sec.addEventListener('click', (e) => {
294
+ const ed = e.target.closest('[data-ob-edit]');
295
+ if (ed) { S.editing.add(ed.dataset.obEdit); const keep = readReview(); draw(); if (keep) for (const [k, v] of Object.entries(keep.fields || {})) { const i = log.querySelector(`input[name="${k}"]`); if (i) i.value = v; } const i = log.querySelector(`input[name="${ed.dataset.obEdit}"]`); if (i) i.focus(); return; }
207
296
  const b = e.target.closest('[data-ob]'); if (!b || b.disabled || S.busy) return;
208
- act(b.dataset.ob);
297
+ e.preventDefault(); act(b.dataset.ob);
209
298
  });
210
299
  sec.addEventListener('change', (e) => {
211
300
  const t = e.target;
212
301
  if (t.name === 'ob-login') { S.profile = t.value; S.session = null; draw(); }
213
- else if (t.name === 'ob-folder') act('folder-mode', t.value);
214
- else if (t.name === 'ob-jira') log.querySelectorAll('input[name="ob-jira"]').forEach((x) => x.closest('.ob-opt').classList.toggle('on', x.checked)); // saved with the review, so typing is never redrawn away
302
+ else if (t.name === 'ob-jira') S.jira = t.value, log.querySelectorAll('input[name="ob-jira"]').forEach((x) => x.closest('.ob-opt').classList.toggle('on', x.checked)); // saved with the review, so typing is never redrawn away
215
303
  });
216
304
  sec.addEventListener('submit', async (e) => {
217
305
  e.preventDefault();
218
306
  const form = e.target.closest('form'); if (!form || S.busy) return;
219
307
  if (form.dataset.obForm === 'say') {
220
308
  const ta = form.querySelector('textarea'); const v = ta.value.trim(); if (!v) return;
221
- const a = readAnswer(v); S.said = v; S.busy = 'say'; draw();
222
- const r = await post('project', { name: a.name, team: a.team, jira: a.jira, link: a.link, description: a.description });
223
- if (!r.ok) S.note = r.error;
224
- else if (a.folder) { const f = await post('folder', { mode: 'existing', root: a.folder }); if (!f.ok) { S.note = 'I couldn’t use ' + a.folder + ': ' + (f.error || 'not found') + ' Choose the folder below.'; await post('folder', { mode: 'new' }); } }
225
- else if (!P().folder) await post('folder', { mode: 'new' });
309
+ ta.value = '';
310
+ const p = P();
311
+ if (p.done && p.fundamentals) { home(v); return; }
312
+ if (!p.project) { if (!p.verified) { S.queued = v; draw(); return; } return answer(v); }
313
+ if (!p.found && !p.reviewed) { // ✕ on the first ✦: a folder path, sources to leave out, or no search
314
+ S.changes.push({ at: 'plan', text: v });
315
+ const disc = (p.discovered && p.discovered.sources) || [];
316
+ const c = readChange(v, disc.map((s) => s.label));
317
+ for (const l of c.leaveOut) { const s = disc.find((x) => x.label === l); if (s) S.excluded.add(s.id); }
318
+ S.asking = '';
319
+ if (c.folder) { const r = await post('folder', { mode: 'existing', root: c.folder }); if (!r.ok) { S.note = r.error; S.asking = 'plan'; } }
320
+ if (c.skip) { const r = await post('review', {}); if (!r.ok) S.note = r.error; }
321
+ else if (!c.folder && !c.leaveOut.length) { S.note = 'I didn’t catch a folder or a source in that. Write a folder path, or the name of a source to leave out.'; S.asking = 'plan'; }
322
+ draw(); return;
323
+ }
324
+ // ✕ on the second ✦, or a word about the context at any time before setup
325
+ S.changes.push({ at: 'context', text: v });
326
+ const typed = readReview() || { fields: {} };
327
+ const fl = readField(v);
328
+ const cur = (p.review && p.review.fields) || {};
329
+ if (fl.purpose && !Object.keys(fl).some((k) => k !== 'purpose') && cur.purpose && cur.purpose.value) { S.note = 'Say which line it is, for example “owner is Dana”, or edit the line above.'; draw(); return; }
330
+ S.busy = 'say'; draw();
331
+ const r = await post('review', { ...typed, fields: { ...(typed.fields || {}), ...fl } });
332
+ if (!r.ok) S.note = r.error; else { S.asking = ''; S.editing = new Set(); }
226
333
  S.busy = ''; draw();
227
334
  } else if (form.dataset.obForm === 'path') {
228
335
  const v = form.querySelector('input').value.trim(); if (!v) return;
229
- const mode = S.folderInput === 'new' ? 'new' : 'existing';
230
- const r = await post('folder', mode === 'new' ? { mode, parent: v } : { mode, root: v });
336
+ const r = await post('folder', { mode: 'existing', root: v });
231
337
  if (!r.ok) S.note = r.error; else { S.folderInput = false; S.note = ''; }
232
338
  draw();
233
- }
339
+ } else if (form.dataset.obForm === 'review') { act('init'); }
234
340
  });
235
341
  sec.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && e.target.matches('textarea')) { e.preventDefault(); e.target.closest('form').requestSubmit(); } });
236
342
  load();
@@ -0,0 +1,52 @@
1
+ /* cozy-reveal.css — Home right after setup (onboarding build B2; pm/cozy-reveal.js). Cozy tokens only; the turns, cards, buttons and
2
+ * composer are the Home chat's own (cozy-home.css, cozy-today.css, cozy-onboard.css). */
3
+ .rv-ticks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-width: 720px; }
4
+ .rv-ticks li { display: flex; gap: 8px; align-items: baseline; color: var(--cz-ink2); font-size: var(--fs-body); line-height: var(--lh-body); animation: rv-in .28s ease-out both; animation-delay: calc(var(--rv-i, 0) * 120ms); }
5
+ .rv-ticks li svg { color: var(--cz-ok); flex: none; transform: translateY(2px); }
6
+ .rv-ticks li span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
7
+ .rv-ticks li em { font-style: normal; color: var(--cz-ink3); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
8
+ @keyframes rv-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
9
+
10
+ .rv-portrait, .rv-draft { max-width: 720px; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; }
11
+ .rv-head { display: flex; gap: 12px; align-items: center; }
12
+ .rv-head b { color: var(--cz-ink); font-size: var(--fs-section); }
13
+ .rv-head p { margin: 2px 0 0; color: var(--cz-ink2); font-size: var(--fs-meta); line-height: var(--lh-meta); }
14
+ .rv-av { width: 40px; height: 40px; border-radius: 12px; background: var(--cz-bluebg); color: var(--cz-blueink); display: inline-flex; align-items: center; justify-content: center; font-weight: var(--fw-strong); flex: none; }
15
+ .rv-cells { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
16
+ .rv-cell { background: var(--cz-well); border-radius: 12px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
17
+ .rv-lab { margin: 0; font-size: var(--fs-badge); letter-spacing: .06em; color: var(--cz-ink3); font-weight: var(--fw-strong); }
18
+ .rv-big { color: var(--cz-ink); font-size: var(--fs-section); font-variant-numeric: tabular-nums; }
19
+ .rv-big i { font-style: normal; color: var(--cz-ink2); font-weight: var(--fw-regular); font-size: var(--fs-meta); }
20
+ .rv-txt { margin: 0; color: var(--cz-ink2); font-size: var(--fs-meta); line-height: var(--lh-meta); overflow-wrap: anywhere; }
21
+ .rv-src { margin: 0; color: var(--cz-ink3); font-size: var(--fs-badge); line-height: var(--lh-badge); overflow-wrap: anywhere; }
22
+ .rv-src a { color: inherit; }
23
+ .rv-bar { height: 6px; border-radius: 999px; background: var(--cz-hair); overflow: hidden; }
24
+ .rv-bar i { display: block; height: 100%; background: var(--cz-blue); border-radius: inherit; }
25
+ .rv-track { position: relative; height: 34px; margin: 2px 6px 0; border-bottom: 2px solid var(--cz-hair); }
26
+ .rv-mon { position: absolute; top: 0; font-size: var(--fs-badge); color: var(--cz-ink3); transform: translateX(-2px); }
27
+ .rv-dot { position: absolute; bottom: -5px; width: 8px; height: 8px; margin-left: -4px; border-radius: 50%; background: var(--cz-blue); }
28
+ .rv-today { position: absolute; bottom: -8px; width: 2px; height: 16px; margin-left: -1px; background: var(--cz-ink); }
29
+ .rv-chk { position: absolute; bottom: -8px; width: 16px; height: 16px; margin-left: -8px; border-radius: 50%; background: var(--cz-okbg); color: var(--cz-ok); display: inline-flex; align-items: center; justify-content: center; }
30
+ .rv-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cz-ink3); font-size: var(--fs-meta); }
31
+
32
+ .rv-kind { margin: 0; display: flex; gap: 6px; align-items: center; color: var(--cz-blueink); font-size: var(--fs-meta); font-weight: var(--fw-strong); }
33
+ .rv-title { color: var(--cz-ink); font-size: var(--fs-section); line-height: 1.3; }
34
+ .rv-draft dl { margin: 0; display: flex; flex-direction: column; gap: 6px; }
35
+ .rv-dl { display: grid; grid-template-columns: 130px 1fr; gap: 8px; align-items: baseline; }
36
+ .rv-dl dt { color: var(--cz-ink3); font-size: var(--fs-meta); }
37
+ .rv-dl dd { margin: 0; color: var(--cz-ink); font-size: var(--fs-body); line-height: var(--lh-body); display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
38
+ .rv-dl dd em { font-style: normal; color: var(--cz-ink3); font-size: var(--fs-badge); }
39
+ .rv-dl dd em a { color: inherit; }
40
+ .rv-in { font: inherit; width: 100%; box-sizing: border-box; border: 1px solid var(--cz-hair); border-radius: 10px; padding: 6px 10px; background: var(--cz-card); color: var(--cz-ink); }
41
+ .rv-next { margin: 0; color: var(--cz-ink2); font-size: var(--fs-meta); line-height: var(--lh-meta); }
42
+ .rv-acts { display: flex; gap: 8px; }
43
+ @media (max-width: 560px) { .rv-dl { grid-template-columns: 1fr; gap: 2px; } }
44
+
45
+ .rv-left { display: flex; flex-direction: column; gap: 4px; padding: 6px 4px 10px; }
46
+ .rv-lrow { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 12px; background: var(--cz-card); border: 0; text-align: left; font: inherit; color: inherit; }
47
+ .rv-lrow b { color: var(--cz-ink); font-size: var(--fs-row); display: inline-flex; gap: 6px; align-items: center; }
48
+ .rv-lrow span { color: var(--cz-ink2); font-size: var(--fs-meta); line-height: var(--lh-meta); }
49
+ .rv-ldraft { cursor: pointer; }
50
+ .rv-ldraft b svg { color: var(--cz-blueink); }
51
+ .rv-lnote { margin: 4px 10px 0; color: var(--cz-ink3); font-size: var(--fs-badge); line-height: var(--lh-badge); }
52
+ @media (prefers-reduced-motion: reduce) { .rv-ticks li { animation: none; } }