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
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* POST /api/onboard/discover the verified profile's connectors, no model call
|
|
15
15
|
* POST /api/onboard/search { sources: [id], local: bool } read-only search of the ticked sources
|
|
16
16
|
* POST /api/onboard/review { fields, jira } the PM's edits to the draft
|
|
17
|
-
* POST /api/onboard/init initialize or reuse, register,
|
|
17
|
+
* POST /api/onboard/init initialize or reuse, register, settle the fundamentals (resumable)
|
|
18
|
+
* POST /api/onboard/fundamental { key } the one-press change of one fundamentals line (lib/onboard-fundamentals.js)
|
|
18
19
|
* POST /api/onboard/reset start again (the folder and anything written stay)
|
|
19
20
|
*/
|
|
20
21
|
const fs = require('fs');
|
|
@@ -22,6 +23,7 @@ const path = require('path');
|
|
|
22
23
|
const crypto = require('crypto');
|
|
23
24
|
const guard = require('./pm-guard.js');
|
|
24
25
|
const P = require('./project-onboard.js');
|
|
26
|
+
const F = require('./onboard-fundamentals.js');
|
|
25
27
|
|
|
26
28
|
function file(ctx) { return path.join(ctx.runtimeDir, 'onboarding', 'project.json'); }
|
|
27
29
|
function journal(ctx) { return path.join(ctx.runtimeDir, 'onboarding', 'project-init.json'); }
|
|
@@ -62,10 +64,19 @@ function hasWork(root) {
|
|
|
62
64
|
return INITIATIVE_DIRS.some((d) => ls(d).some((n) => n.endsWith('.md') && !/^README|template/i.test(n)))
|
|
63
65
|
|| RECORD_DIRS.some((d) => ls(d).some((n) => n.endsWith('.jsonl') && filled(d, n)));
|
|
64
66
|
}
|
|
67
|
+
/** The installer's home folder while it is still an empty kit (PMOS in it, no work yet), as folder facts; otherwise null. */
|
|
68
|
+
function installerKit(ctx) {
|
|
69
|
+
if (!ctx.homeRoot || !(ctx.workspaceList ? ctx.workspaceList() : []).some((w) => w && w.root && path.resolve(w.root) === path.resolve(ctx.homeRoot))) return null;
|
|
70
|
+
const pm = P.pmosIn(ctx.homeRoot);
|
|
71
|
+
if (!pm || !pm.kit || hasWork(ctx.homeRoot)) return null;
|
|
72
|
+
const facts = P.folderFacts(ctx.homeRoot);
|
|
73
|
+
return facts.ok && facts.exists && !facts.inside_other_git ? facts : null;
|
|
74
|
+
}
|
|
65
75
|
function view(ctx) {
|
|
66
76
|
const { st, bound } = fresh(ctx, read(ctx));
|
|
67
77
|
const ws = st.done && st.done.ws && ctx.getWs(st.done.ws);
|
|
68
|
-
|
|
78
|
+
const fundamentals = st.fundamentals ? { at: st.fundamentals.at, lines: st.fundamentals.lines, error: st.fundamentals.error || null } : null;
|
|
79
|
+
return { ok: true, progress: { ...st, verified: !!bound, done: ws ? st.done : null, fundamentals }, claude: ctx.claudeSetup.list(), platform: process.platform,
|
|
69
80
|
// a project the PM already has: a registered workspace with PMOS in it, or with the planning/ folder a PMOS workspace keeps its records in
|
|
70
81
|
// ...that holds work: an initiative or a filled record file. A PMOS folder with none is not one: the one-line installer makes ~/PMOS with an empty kit
|
|
71
82
|
// and registers it, so counting the bare folder skipped setup for every new user (bug 2026-11-02, Windows).
|
|
@@ -73,7 +84,7 @@ function view(ctx) {
|
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
async function handle(req, res, url, ctx) {
|
|
76
|
-
const m = /^\/api\/onboard\/(state|own|login|check|project|folder|discover|search|review|init|reset)$/.exec(url.pathname);
|
|
87
|
+
const m = /^\/api\/onboard\/(state|own|login|check|project|folder|discover|search|review|init|fundamental|reset)$/.exec(url.pathname);
|
|
77
88
|
if (!m) return false;
|
|
78
89
|
const send = (body, code = 200) => { ctx.json(res, body, code); return true; };
|
|
79
90
|
if (!ctx.claudeSetup) return send({ ok: false, error: 'Claude setup is unavailable.' }, 503);
|
|
@@ -123,6 +134,15 @@ async function handle(req, res, url, ctx) {
|
|
|
123
134
|
if (r.cancelled || !r.path) return send({ ok: false, cancelled: true });
|
|
124
135
|
if (b.mode === 'new') parent = r.path; else root = r.path;
|
|
125
136
|
}
|
|
137
|
+
// One folder per product: the one-line installer already made ~/PMOS a Git repository with an empty kit and opened it as the home
|
|
138
|
+
// product. The first proposal is that folder, so setup fills it (and renames it to the product) instead of nesting a second
|
|
139
|
+
// repository inside it and leaving an empty "PMOS" product behind (onboarding grill, 2026-11-04). Choosing "Make a new folder"
|
|
140
|
+
// afterwards still proposes a new one.
|
|
141
|
+
const reuse = b.mode === 'new' && !parent && !st.folder && installerKit(ctx);
|
|
142
|
+
if (reuse) {
|
|
143
|
+
write(ctx, { ...st, folder: { mode: 'existing', root: reuse.root, facts: reuse, chosen: false, reused: true } });
|
|
144
|
+
return send(view(ctx));
|
|
145
|
+
}
|
|
126
146
|
if (b.mode === 'new') {
|
|
127
147
|
const pr = P.proposeFolder(parent, st.project.name);
|
|
128
148
|
if (!pr.ok) return send(pr, 400);
|
|
@@ -150,7 +170,8 @@ async function handle(req, res, url, ctx) {
|
|
|
150
170
|
const picked = all.filter((s) => Array.isArray(b.sources) && b.sources.includes(s.id) && s.status === 'connected');
|
|
151
171
|
const local = b.local !== false && st.folder && st.folder.mode === 'existing';
|
|
152
172
|
if (!picked.length && !local) return send({ ok: false, error: 'Tick at least one source, or continue with your own description.' }, 400);
|
|
153
|
-
let found = { ok: true, sources: [], findings: [], jira: [], fields: {} };
|
|
173
|
+
let found = { ok: true, sources: [], findings: [], jira: [], fields: {}, highlights: [], goal: null, facts: {} };
|
|
174
|
+
const startedAt = new Date().toISOString(); // the reveal's ticks count from here (lib/first-reveal.js)
|
|
154
175
|
if (picked.length) {
|
|
155
176
|
const inv = await P.inventory({ bound, sources: picked });
|
|
156
177
|
found = inv.ok ? await P.search({ bound, project: st.project, sources: picked, tools: inv.tools }) : { ok: false, error: inv.error };
|
|
@@ -160,12 +181,12 @@ async function handle(req, res, url, ctx) {
|
|
|
160
181
|
if (local) found.sources.push({ id: 'folder', label: 'The folder', state: folder && folder.ok ? (folder.readme || folder.commits ? 'found' : 'empty') : 'failed', count: folder && folder.commits || 0 });
|
|
161
182
|
const jiraMatches = found.jira.length ? found.jira : [];
|
|
162
183
|
const draft = P.draftContext({ project: st.project, folder: folder || (st.folder && st.folder.facts), found });
|
|
163
|
-
write(ctx, { ...st, found: { fingerprint: bound.fingerprint, at: new Date().toISOString(), sources: found.sources, findings: found.findings, jira: jiraMatches }, review: { fields: draft, jira: jiraMatches.length === 1 ? jiraMatches[0].key : null } });
|
|
184
|
+
write(ctx, { ...st, found: { fingerprint: bound.fingerprint, started_at: startedAt, at: new Date().toISOString(), sources: found.sources, findings: found.findings, jira: jiraMatches, highlights: found.highlights || [], goal: found.goal || null, facts: found.facts || {} }, review: { fields: draft, jira: jiraMatches.length === 1 ? jiraMatches[0].key : null } });
|
|
164
185
|
return send(view(ctx));
|
|
165
186
|
}
|
|
166
187
|
if (verb === 'review') {
|
|
167
188
|
const fields = { ...((st.review && st.review.fields) || P.draftContext({ project: st.project, folder: st.folder && st.folder.facts, found: null })) };
|
|
168
|
-
for (const k of ['name', 'purpose', 'team', 'owner', 'timeline', 'goals']) {
|
|
189
|
+
for (const k of ['name', 'purpose', 'team', 'owner', 'timeline', 'weekly', 'goals']) {
|
|
169
190
|
if (!(b.fields && k in b.fields)) continue;
|
|
170
191
|
const v = str(b.fields[k], 400);
|
|
171
192
|
fields[k] = v ? (fields[k] && fields[k].value === v ? fields[k] : { value: v, source: 'you' }) : null;
|
|
@@ -184,15 +205,31 @@ async function handle(req, res, url, ctx) {
|
|
|
184
205
|
const fields = (st.review && st.review.fields) || P.draftContext({ project: st.project, folder: st.folder.facts, found: null });
|
|
185
206
|
const jira = jiraKey && jiraKey !== 'none' ? (matches.find((j) => j.key === jiraKey) || null) : null;
|
|
186
207
|
const name = (fields.name && fields.name.value) || st.project.name;
|
|
187
|
-
const
|
|
208
|
+
const fg = st.found && st.found.goal, gf = fg && (st.found.findings || []).find((x) => x.id === fg.finding);
|
|
209
|
+
const goal = fg ? { objective: fg.objective, kr: fg.kr, source_label: gf ? gf.source_label : null } : null;
|
|
210
|
+
const plan = { mode: st.folder.mode, root: st.folder.root, name, context: { name, fields, jira, goal, sources: (st.found && st.found.findings || []).slice(0, 20) } };
|
|
188
211
|
const r = P.initialize({ plan, journalFile: journal(ctx) });
|
|
189
212
|
if (!r.ok) { write(ctx, { ...st, init: { ok: false, step: r.step, error: r.error, steps: r.journal && r.journal.steps } }); return send({ ...view(ctx), ok: false, error: r.error, step: r.step }); }
|
|
190
213
|
const w = ctx.registerWorkspace(r.root, name);
|
|
191
214
|
if (!w) { write(ctx, { ...st, init: { ok: false, step: 'register', error: 'The project could not be added to My Work.', steps: r.journal.steps } }); return send({ ...view(ctx), ok: false, error: 'The project could not be added to My Work.' }); }
|
|
215
|
+
// A3: the fundamentals are settled for the PM right after init, read from the project-context.md just written. Once per product: a
|
|
216
|
+
// retried init keeps the card it already has, so a change the PM pressed is not undone.
|
|
217
|
+
let fundamentals = st.fundamentals && st.fundamentals.root === r.root ? st.fundamentals : null;
|
|
218
|
+
if (!fundamentals) {
|
|
219
|
+
try { fundamentals = F.settle({ root: r.root, ws: w.id, sources: (st.found && st.found.sources) || [], fresh: !!(r.journal.steps.kit && r.journal.steps.kit.detail === 'installed'), refresh: ctx.refreshSources ? ctx.refreshSources() : F.refreshAt(ctx.runtimeDir), ...(ctx.launcher !== undefined ? { launcher: ctx.launcher } : {}) }); }
|
|
220
|
+
catch { fundamentals = { root: r.root, ws: w.id, lines: [], did: {}, error: 'Some fundamentals could not be settled. Change them in Settings.' }; }
|
|
221
|
+
}
|
|
192
222
|
if (ctx.broadcast) ctx.broadcast({ t: 'focus', ws: w.id });
|
|
193
|
-
write(ctx, { ...st, init: { ok: true, steps: r.journal.steps }, done: { ws: w.id, name, root: r.root, context: r.journal.steps.context && r.journal.steps.context.detail, at: new Date().toISOString() } });
|
|
223
|
+
write(ctx, { ...st, init: { ok: true, steps: r.journal.steps }, fundamentals, done: { ws: w.id, name, root: r.root, context: r.journal.steps.context && r.journal.steps.context.detail, at: new Date().toISOString() } });
|
|
194
224
|
return send(view(ctx));
|
|
195
225
|
}
|
|
226
|
+
if (verb === 'fundamental') {
|
|
227
|
+
if (!st.fundamentals) return send({ ok: false, error: 'Set up the project first.', need: 'init' }, 409);
|
|
228
|
+
const r = F.change(st.fundamentals, str(b.key, 20), { refresh: ctx.refreshSources ? ctx.refreshSources() : F.refreshAt(ctx.runtimeDir), ...(ctx.launcher !== undefined ? { launcher: ctx.launcher } : {}) });
|
|
229
|
+
if (!r.ok) return send({ ...view(ctx), ok: false, error: r.error });
|
|
230
|
+
if (r.state !== st.fundamentals) write(ctx, { ...st, fundamentals: r.state });
|
|
231
|
+
return send({ ...view(ctx), follow: r.follow || null });
|
|
232
|
+
}
|
|
196
233
|
} catch { return send({ ok: false, error: 'This step could not finish. Nothing you had was removed. Try again.' }, 500); }
|
|
197
234
|
return false;
|
|
198
235
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* POST /api/proposals/keep { ws, board, initiative, ids?: [id] | all: true, choices?: { <from>><to>: 'keep' | 'ignore' } }
|
|
9
9
|
* → { ok, batch, said, counts, props } ✓ one proposal, or ✓ in the header (all: every one Claude is sure of;
|
|
10
10
|
* Check these and a Jira ticket's comment never ride on it). One batch, one toast, one Undo.
|
|
11
|
-
* POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7); the line
|
|
11
|
+
* POST /api/proposals/drop { ws, board, initiative, sig, why? } → { ok } why: the PM's reason (the idle lab asks for one); ✕ on a proposal or on one item of it, for good (rule 7); the line
|
|
12
12
|
* also keeps its facts (PR.factsOf), so a re-sectioned task or a new day does not bring it back
|
|
13
13
|
* POST /api/proposals/undo { ws, board, batch } → { ok, back, kept } the whole batch; a task changed since keeps its change
|
|
14
14
|
* POST /api/proposals/stop { ws, board, initiative } → { ok } ■ Stop: the look ends; what was found so far stays proposed
|
|
@@ -142,6 +142,14 @@ function applyOne(ctx, p, g, f, ini, x, choices, outbox) {
|
|
|
142
142
|
// the record's own description (initiative-description-field): ✓ writes its description line, Undo puts back what was there
|
|
143
143
|
const r = require('./initiative-writer.js').setDescription(p.w.root, ini, (x.data || {}).text, { actor: g.actor });
|
|
144
144
|
if (r.ok && !r.same) add({ op: 'description', from: r.from, to: r.text });
|
|
145
|
+
} else if (x.group === 'prototyped' || x.group === 'improved' || x.group === 'scanned') {
|
|
146
|
+
// the idle lab (lib/idle-lab.js): ✓ keeps it as the initiative's own document. A prototype is each specimen of round 1, or the new
|
|
147
|
+
// round; a scan finding is evidence. The specimen stays in the runtime folder; the document links it (GET /api/lab/specimen)
|
|
148
|
+
const LAB = require('./idle-lab.js'), d = x.data || {}, name = (sp) => 'Prototype ' + sp.key + ' · ' + sp.title;
|
|
149
|
+
const docOf = (title, ref, kind) => add({ op: 'doc', id: 'fd-' + PR.hash(ini + '|' + x.id + '|' + (ref || title)), title: String(title).slice(0, 140), source: 'Claude, while you were away', at: x.at || '', ref, kind, claude: true });
|
|
150
|
+
if (x.group === 'prototyped') for (const sp of d.specimens || []) docOf(name(sp), LAB.specimenUrl(sp.file), 'prototype');
|
|
151
|
+
else if (x.group === 'improved') docOf('Prototype ' + (d.base || '') + ' · round ' + d.round + ': ' + (d.changed || ''), LAB.specimenUrl(d.file), 'prototype');
|
|
152
|
+
else docOf(x.why, ((x.sources || [])[0] || {}).ref || '', 'evidence');
|
|
145
153
|
} else if (x.group === 'grill') {
|
|
146
154
|
// a grill finding (lib/grill.js): ✓ adds its next step as a task, with the finding and its sources in the notes
|
|
147
155
|
// ✎ on the finding: the PM's own words for the next step (choices[id]) replace Claude's
|
|
@@ -279,7 +287,9 @@ const WRITES = {
|
|
|
279
287
|
if (it) { facts = PR.factsOf(x, it); break; }
|
|
280
288
|
}
|
|
281
289
|
} catch { facts = []; }
|
|
282
|
-
|
|
290
|
+
// the PM's reason, when given (the idle lab's ✕ asks for one; Claude learns from it)
|
|
291
|
+
const why = typeof body.why === 'string' ? body.why.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
|
|
292
|
+
PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig, ...(facts.length ? { facts } : {}), ...(why ? { why } : {}) });
|
|
283
293
|
ctx.json(res, { ok: true });
|
|
284
294
|
},
|
|
285
295
|
'/api/proposals/undo': (ctx, res, body, g, p) => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-reveal.js — Home right after setup (onboarding build B2; lib/first-reveal.js has the model and the one write).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/first-reveal?ws=<id> → the reveal of a product just set up, or { none: true }
|
|
5
|
+
* POST /api/first-reveal/keep { ws, outcome? } → ✓ on the drafted initiative: it is created with its expected outcome and check date
|
|
6
|
+
* POST /api/first-reveal/drop { ws } → ✕: nothing is written; the draft does not come back for this product
|
|
7
|
+
*
|
|
8
|
+
* It reads the onboarding progress of lib/routes-onboard.js (the per-user runtime folder) and nothing from a connector: what the portrait
|
|
9
|
+
* shows is what the read-only search returned during setup. Every POST is the PM's own page press (pm-guard requirePagePress).
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const guard = require('./pm-guard.js');
|
|
15
|
+
const R = require('./first-reveal.js');
|
|
16
|
+
|
|
17
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
18
|
+
const localDay = (d = new Date()) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
19
|
+
const readJsonFile = (f) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return v && typeof v === 'object' ? v : null; } catch { return null; } };
|
|
20
|
+
const progressOf = (ctx) => readJsonFile(path.join(ctx.runtimeDir, 'onboarding', 'project.json')) || {};
|
|
21
|
+
const journalOf = (ctx) => readJsonFile(path.join(ctx.runtimeDir, 'onboarding', 'project-init.json'));
|
|
22
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
|
23
|
+
|
|
24
|
+
/** The default workflow's name for new initiatives of this product (D99; Product feature when none is set). */
|
|
25
|
+
function workflowName(ctx, w) {
|
|
26
|
+
try {
|
|
27
|
+
const WS = require('./workflows-store.js');
|
|
28
|
+
const WF = require(require('./repo-script.js').repoScript('workflows.js'));
|
|
29
|
+
const dir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
30
|
+
return (dir && WF.defaultOf(WS.library(dir)).name) || 'Product feature';
|
|
31
|
+
} catch { return 'Product feature'; }
|
|
32
|
+
}
|
|
33
|
+
function hasKr(w) { return !!R.firstKr(w.root); }
|
|
34
|
+
|
|
35
|
+
async function handle(req, res, url, ctx) {
|
|
36
|
+
const m = /^\/api\/first-reveal(?:\/(keep|drop))?$/.exec(url.pathname);
|
|
37
|
+
if (!m) return false;
|
|
38
|
+
const send = (body, code = 200) => { ctx.json(res, body, code); return true; };
|
|
39
|
+
if (!ctx.runtimeDir) return send({ ok: false, error: 'PMOS has no runtime folder.' }, 409);
|
|
40
|
+
if (!m[1]) {
|
|
41
|
+
if (req.method !== 'GET') return send({ ok: false, error: 'GET only' }, 405);
|
|
42
|
+
const ws = url.searchParams.get('ws') || '';
|
|
43
|
+
const st = progressOf(ctx);
|
|
44
|
+
if (!ws || !st.done || st.done.ws !== ws) return send({ ok: true, none: true });
|
|
45
|
+
const w = ctx.getWs(ws);
|
|
46
|
+
if (!w) return send({ ok: true, none: true });
|
|
47
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get('today') || '') ? url.searchParams.get('today') : localDay();
|
|
48
|
+
return send(R.model({ st, journal: journalOf(ctx), ws, today, workflow: workflowName(ctx, w), decided: R.read(ctx.runtimeDir, ws), home: os.homedir(), hasKr: hasKr(w) }));
|
|
49
|
+
}
|
|
50
|
+
if (req.method !== 'POST') return send({ ok: false, error: 'POST only' }, 405);
|
|
51
|
+
const got = await ctx.readJson(req);
|
|
52
|
+
if (!got.ok || !got.body || typeof got.body !== 'object') return send({ ok: false, error: 'Invalid JSON.' }, 400);
|
|
53
|
+
const b = got.body, g = guard.requirePagePress(req, b);
|
|
54
|
+
if (!g.ok) return send({ ok: false, error: g.error }, g.status);
|
|
55
|
+
const ws = String(b.ws || '');
|
|
56
|
+
if (!ID_RE.test(ws)) return send({ ok: false, error: 'Name the product.' }, 400);
|
|
57
|
+
const st = progressOf(ctx);
|
|
58
|
+
if (!st.done || st.done.ws !== ws) return send({ ok: false, error: 'This product was not set up here.' }, 409);
|
|
59
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(String(b.today || '')) ? String(b.today) : localDay();
|
|
60
|
+
try {
|
|
61
|
+
if (m[1] === 'drop') {
|
|
62
|
+
const d = R.read(ctx.runtimeDir, ws);
|
|
63
|
+
if (!d.kept && !d.dropped) R.write(ctx.runtimeDir, ws, { dropped: new Date().toISOString() });
|
|
64
|
+
return send({ ok: true, dropped: true });
|
|
65
|
+
}
|
|
66
|
+
const r = R.keep(ctx, g, { ws, outcome: typeof b.outcome === 'string' ? b.outcome : '', today, st });
|
|
67
|
+
if (!r.ok) return send({ ok: false, error: r.error, field: r.field || null }, r.status || 400);
|
|
68
|
+
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(ws);
|
|
69
|
+
return send(r);
|
|
70
|
+
} catch { return send({ ok: false, error: 'This did not finish. Nothing was removed. Try again.' }, 500); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { prefixes: ['/api/first-reveal'], handle };
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* 1. closes a scan that was alive when the server stopped (`scan-end`, reason "interrupted"), and writes the report documents it already held;
|
|
8
8
|
* 2. starts at most one job, and none while one is running (one scan at a time across the workspace):
|
|
9
9
|
* - a suggest run for a workspace with no watch list (lib/scan.js suggestDue), or
|
|
10
|
-
* - the portfolio scan, when it is due (lib/scan.js dueNow: the cadence, 09:00, the working hours, quiet hours, once per day)
|
|
10
|
+
* - the portfolio scan, when it is due (lib/scan.js dueNow: the cadence, 09:00, the working hours, quiet hours, once per day) and the
|
|
11
|
+
* queue gives it an idle turn (`idle`, lib/bg-queue.js step 5: the PM stepped away, or the last hour of the working day came first).
|
|
11
12
|
* The routes call the other three: Scan now (`startNow`: at most 3 a day, never in quiet hours, one at a time), an approved research proposal
|
|
12
13
|
* (`startResearch`) and Stop (`stop`).
|
|
13
14
|
*
|
|
@@ -292,7 +293,7 @@ function create(deps) {
|
|
|
292
293
|
return { ok: true, scans: stopped };
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
function tick({ now = Date.now(), env } = {}) {
|
|
296
|
+
function tick({ now = Date.now(), env, idle = true } = {}) {
|
|
296
297
|
if (off(env || deps.env)) return { off: true };
|
|
297
298
|
const summary = { closed: 0, started: 0 };
|
|
298
299
|
const settings = deps.settings();
|
|
@@ -318,7 +319,7 @@ function create(deps) {
|
|
|
318
319
|
if (got && got.initiatives.length) { startSuggest(w, file, got, now); summary.started++; }
|
|
319
320
|
continue;
|
|
320
321
|
}
|
|
321
|
-
if (S.dueNow(state, null, now, { open }).ok) {
|
|
322
|
+
if (S.dueNow(state, null, now, { open, idle }).ok) {
|
|
322
323
|
const sel = S.selectScan(state, {});
|
|
323
324
|
if (!sel.ok) continue;
|
|
324
325
|
let ctx = { boards: [], initiatives: [] };
|
package/workspace/lib/scan.js
CHANGED
|
@@ -430,7 +430,7 @@ function isWorkingDay(ms, cadence) {
|
|
|
430
430
|
return false;
|
|
431
431
|
}
|
|
432
432
|
const slotMs = (ms) => { const d = new Date(ms); const [h, m] = SLOT.split(':').map(Number); d.setHours(h, m, 0, 0); return d.getTime(); };
|
|
433
|
-
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs');
|
|
433
|
+
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, when you step away after 09:00' : cadence === 'weekly' ? 'Every Monday, when you step away after 09:00' : 'Off. No scan runs');
|
|
434
434
|
|
|
435
435
|
/** The report types a scan can write, given the repository switch and the number of web items it will search. Web reports (benchmark, competitive, insight)
|
|
436
436
|
* need an item or the repository; the productivity report needs the repository. */
|
|
@@ -441,8 +441,9 @@ function effectiveTypes(types, { repo, items }) {
|
|
|
441
441
|
|
|
442
442
|
/** May a scheduled scan start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen). `board` null = the portfolio.
|
|
443
443
|
* → { ok } | { ok: false, why }. A scan that missed 09:00 starts at the first tick inside the working hours of that day, never outside them.
|
|
444
|
-
* One scan per day across the portfolio, whoever started it: a manual scan today is today's scan.
|
|
445
|
-
|
|
444
|
+
* One scan per day across the portfolio, whoever started it: a manual scan today is today's scan. `idle` is the turn lib/bg-queue.js gave
|
|
445
|
+
* (idle lab, frame I4): the daily scan starts in the first idle stretch of the day after 09:00, or in the last working hour if none came. */
|
|
446
|
+
function dueNow(state, board, now, { open = true, idle = true } = {}) {
|
|
446
447
|
const cadence = cadenceOf(state);
|
|
447
448
|
if (cadence === 'off') return { ok: false, why: 'off' };
|
|
448
449
|
const kept = keptItems(state, board);
|
|
@@ -453,6 +454,7 @@ function dueNow(state, board, now, { open = true } = {}) {
|
|
|
453
454
|
if (scanDay(state, board, D.localDay(now)).length) return { ok: false, why: 'done-today' };
|
|
454
455
|
if (openScans(state).some((x) => !board || x.board === board)) return { ok: false, why: 'running' };
|
|
455
456
|
if (!effectiveTypes(state.setting.types, { repo: state.setting.repo, items: kept.length }).length) return { ok: false, why: 'no-reports' };
|
|
457
|
+
if (!idle) return { ok: false, why: 'waiting-for-idle' };
|
|
456
458
|
return { ok: true };
|
|
457
459
|
}
|
|
458
460
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.524.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.524.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
/* project-onboard-flow-test.js — the project-first setup conversation in the real Home page, against the real server, with a FIXTURE `claude`
|
|
4
4
|
* (fixtures/fake-claude-onboard.js). It proves the plumbing and the page, not a live account: no real sign-in, no real connector.
|
|
5
|
-
* Covers: login
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Covers (onboarding build B1): the silent login check (a failed check asks once, as ✦ Sign in), the composer open from the first screen, the
|
|
6
|
+
* product question, one ✦ for the folder + read-only search + sign-ins with ✓ ✕ only, ✕ answered in the composer, the read-only search flags,
|
|
7
|
+
* a denied source contributing nothing, the source tiles, two Jira matches needing a choice, a real kit install in a temporary folder, the
|
|
8
|
+
* fundamentals card with a source on every line and a one-press change, reuse of an existing PMOS folder, and desktop and phone widths.
|
|
9
|
+
* SHOTS=<dir> saves screenshots. */
|
|
8
10
|
const fs = require('fs');
|
|
9
11
|
const os = require('os');
|
|
10
12
|
const path = require('path');
|
|
@@ -33,76 +35,88 @@ const shot = async (p, name) => { if (SHOTS) { fs.mkdirSync(SHOTS, { recursive:
|
|
|
33
35
|
const log = () => fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : [];
|
|
34
36
|
const btn = (label) => p.locator('#obChat button', { hasText: label }).first();
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
const press = (act) => p.locator(`#obChat [data-ob="${act}"]`).last().click();
|
|
39
|
+
const say = async (pg, text) => { await pg.fill('#obChat textarea', text); await pg.keyboard.press('Enter'); };
|
|
40
|
+
|
|
41
|
+
await step('The old Welcome page opens the Home conversation; the login is checked by itself and the composer is open', async () => {
|
|
37
42
|
await p.goto(T.BASE + '/premium-welcome.html?ws=x&step=sources');
|
|
38
43
|
assert.match(p.url(), /\/premium\.html\?onboard=1$/);
|
|
39
|
-
await p.waitForSelector('#obChat .ob-
|
|
44
|
+
await p.waitForSelector('#obChat .ob-prop');
|
|
40
45
|
assert(await p.locator('#hmChat').isHidden(), 'one chat at a time'); assert(await p.locator('#hmList').isVisible(), 'the list stays');
|
|
46
|
+
assert.match(await p.locator('#obChat .ob-prop').innerText(), /Sign in to Claude Code/, 'only a failed check asks');
|
|
47
|
+
assert(!(await p.locator('#obChat input[name="ob-login"]').count()), 'no login picker on the first screen');
|
|
48
|
+
assert(await p.locator('#obChat textarea').isEnabled(), 'the composer is open from the first screen');
|
|
49
|
+
assert.match(await p.locator('#obChat').innerText(), /What product or project is this for\?/);
|
|
50
|
+
const check = log().find((a) => a.includes('-p') && a.includes('--output-format') && a[a.indexOf('--output-format') + 1] === 'json');
|
|
51
|
+
assert(check && check.includes('--safe-mode') && check[check.indexOf('--tools') + 1] === '', 'the silent check is the bounded, tool-free check');
|
|
52
|
+
await shot(p, 'onboard-1-first-open');
|
|
41
53
|
});
|
|
42
|
-
await step('
|
|
43
|
-
await
|
|
44
|
-
await
|
|
45
|
-
await p.waitForSelector('#obChat .ob-bad');
|
|
46
|
-
assert.match(await p.locator('#obChat .ob-bad').innerText(), /won’t switch to a different account/);
|
|
47
|
-
assert(await p.locator('#obChat textarea').isDisabled(), 'no project question before a passing test');
|
|
48
|
-
});
|
|
49
|
-
await step('A passing test of that login asks about the project', async () => {
|
|
54
|
+
await step('An answer typed before the check passes waits; a passing test of another login sends it', async () => {
|
|
55
|
+
await say(p, 'Checkout redesign, payments team. Jira key PAY.');
|
|
56
|
+
await p.waitForFunction(() => /I start as soon as the Claude check passes/.test(document.querySelector('#obChat').innerText));
|
|
50
57
|
fs.writeFileSync(MODE, 'ready');
|
|
58
|
+
await press('chooser');
|
|
51
59
|
await btn('Test this login').click();
|
|
52
60
|
await p.waitForSelector('#obChat .ob-good');
|
|
53
|
-
assert(await p.locator('#obChat
|
|
54
|
-
const check = log().find((a) => a.includes('-p') && a.includes('--output-format') && a[a.indexOf('--output-format') + 1] === 'json');
|
|
55
|
-
assert(check && check.includes('--safe-mode') && check[check.indexOf('--tools') + 1] === '', 'the test is the bounded, tool-free check');
|
|
56
|
-
await shot(p, 'onboard-2-tested');
|
|
61
|
+
assert.match(await p.locator('#obChat .ob-good').innerText(), /signed in on this computer, and I just tested it/);
|
|
57
62
|
});
|
|
58
|
-
await step('
|
|
59
|
-
await p.
|
|
60
|
-
await p.
|
|
61
|
-
await p.waitForSelector('#obChat input[name="ob-folder"][value="new"]');
|
|
62
|
-
const card = await p.locator('#obChat .ob-card').first().innerText();
|
|
63
|
+
await step('One ✦ proposes the folder, the read-only search and what needs a sign-in; nothing is created or searched before ✓', async () => {
|
|
64
|
+
await p.waitForSelector('#obChat [data-ob="plan-yes"]', { timeout: 15000 });
|
|
65
|
+
const card = await p.locator('#obChat .ob-prop').last().innerText();
|
|
63
66
|
assert(card.includes(path.join(HOME, 'PMOS', 'checkout-redesign')), card);
|
|
67
|
+
assert.match(card, /Atlassian, Slack for “Checkout redesign” and PAY/); assert.match(card, /Google Drive needs signing in inside Claude/);
|
|
64
68
|
assert(!fs.existsSync(path.join(HOME, 'PMOS', 'checkout-redesign')), 'nothing created before the confirmation');
|
|
69
|
+
assert(!log().some((a) => a.includes('--allowedTools')), 'nothing searched before ✓');
|
|
70
|
+
assert(log().some((a) => a[0] === 'mcp' && a[1] === 'list'), 'the sources come from the verified login, with no model call');
|
|
71
|
+
assert.equal(await p.locator('#obChat .ob-prop').last().locator('button.ut-ib').count(), 2, '✓ and ✕ only');
|
|
72
|
+
await shot(p, 'onboard-2-plan');
|
|
65
73
|
});
|
|
66
|
-
await step('
|
|
67
|
-
|
|
68
|
-
await
|
|
69
|
-
await p
|
|
70
|
-
|
|
71
|
-
assert(await p.locator('#obChat input[name="ob-src"][value="claude.ai Google Drive"]').isDisabled(), 'a source that needs sign-in cannot be ticked');
|
|
72
|
-
await shot(p, 'onboard-3-folder-sources');
|
|
74
|
+
await step('✕ asks what to change in the composer; leaving a source out changes the ✦', async () => {
|
|
75
|
+
await press('plan-no');
|
|
76
|
+
await p.waitForFunction(() => /What should I change\?/.test(document.querySelector('#obChat').innerText));
|
|
77
|
+
await say(p, 'leave out Google Drive');
|
|
78
|
+
await p.waitForFunction(() => /Left out: Google Drive/.test(document.querySelector('#obChat').innerText));
|
|
73
79
|
});
|
|
74
|
-
await step('
|
|
75
|
-
await
|
|
76
|
-
await p.waitForSelector('#obChat
|
|
80
|
+
await step('✓ searches read-only by its flags; a denied source contributes nothing; the findings are source tiles', async () => {
|
|
81
|
+
await press('plan-yes');
|
|
82
|
+
await p.waitForSelector('#obChat .ob-tile', { timeout: 30000 });
|
|
77
83
|
const run = log().find((a) => a.includes('--allowedTools'));
|
|
78
84
|
const at = (f) => run[run.indexOf(f) + 1];
|
|
79
85
|
assert.equal(at('--permission-mode'), 'dontAsk'); assert.equal(at('--tools'), ''); assert.deepEqual(JSON.parse(at('--settings')), { disableAllHooks: true });
|
|
80
|
-
|
|
81
|
-
assert.deepEqual(allow.sort(), ['mcp__claude_ai_Atlassian__getVisibleJiraProjects', 'mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql', 'mcp__claude_ai_Slack__slack_search_public']);
|
|
86
|
+
assert.deepEqual(at('--allowedTools').split(',').sort(), ['mcp__claude_ai_Atlassian__getVisibleJiraProjects', 'mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql', 'mcp__claude_ai_Slack__slack_search_public']);
|
|
82
87
|
const text = await p.locator('#obChat').innerText();
|
|
83
|
-
assert.match(text, /
|
|
84
|
-
|
|
85
|
-
assert.
|
|
86
|
-
|
|
88
|
+
assert.match(text, /I found one thing about/); assert.match(text, /Slack: This login is not allowed to search it/); assert(!text.includes('#checkout'), 'nothing from the denied source');
|
|
89
|
+
const ctxCard = await p.locator('#obChat form[data-ob-form="review"]').innerText();
|
|
90
|
+
assert.match(ctxCard, /Reduce checkout drop-off on mobile/); assert.match(ctxCard, /Owner\s*Not found\. Tell me, or leave blank/);
|
|
91
|
+
assert(!/Invented Person/.test(ctxCard), 'a field cited from a failed source stays empty');
|
|
92
|
+
await shot(p, 'onboard-3-found');
|
|
87
93
|
});
|
|
88
|
-
await step('Setup waits for the Jira choice,
|
|
89
|
-
await
|
|
94
|
+
await step('Setup waits for the Jira choice, installs PMOS for real, and shows the fundamentals card', async () => {
|
|
95
|
+
await press('init');
|
|
90
96
|
await p.waitForFunction(() => /Choose which Jira project/.test((document.querySelector('#obChat .ob-live') || {}).innerText || ''), null, { timeout: 15000 });
|
|
91
97
|
await p.check('#obChat input[name="ob-jira"][value="PAY"]');
|
|
98
|
+
await p.locator('#obChat [data-ob-edit="goals"]').click();
|
|
92
99
|
await p.fill('#obChat input[name="goals"]', 'Raise mobile checkout conversion');
|
|
93
|
-
await
|
|
94
|
-
try { await p.
|
|
95
|
-
await p.waitForSelector('#obChat .ob-done');
|
|
100
|
+
await press('init');
|
|
101
|
+
try { await p.waitForSelector('#obChat .ob-fund', { timeout: 60000 }); } catch (e) { await shot(p, 'onboard-x-stuck'); console.log(await p.locator('#obChat').innerText()); throw e; }
|
|
96
102
|
const root = path.join(HOME, 'PMOS', 'checkout-redesign');
|
|
97
|
-
for (const f of ['.git', 'pmos/AGENTS.md', 'pmos/state', 'CLAUDE.md', '.pmos-profile']) assert(fs.existsSync(path.join(root, f)), f);
|
|
103
|
+
for (const f of ['.git', 'pmos/AGENTS.md', 'pmos/state', 'CLAUDE.md', '.pmos-profile', 'pmos/planning/okrs/okrs-draft.md']) assert(fs.existsSync(path.join(root, f)), f);
|
|
98
104
|
const ctxText = fs.readFileSync(path.join(root, 'pmos', 'planning', 'project-context.md'), 'utf8');
|
|
99
105
|
assert.match(ctxText, /Reduce checkout drop-off on mobile \(source: Atlassian, Checkout brief/); assert.match(ctxText, /Raise mobile checkout conversion \(source: the PM\)/);
|
|
100
106
|
assert.match(ctxText, /Jira:\*\* PAY · Payments Platform/); assert(!/Invented Person/.test(ctxText));
|
|
107
|
+
const fund = await p.locator('#obChat .ob-fund').innerText();
|
|
108
|
+
assert.match(fund, /A document product/); assert.match(fund, /Workflow: Product feature/); assert.match(fund, /Jira PAY linked: read-only/); assert.match(fund, /About: “Reduce checkout drop-off on mobile”/);
|
|
109
|
+
assert(!/Applications/.test(fund), 'a scratch server writes no launcher, and says nothing about it');
|
|
110
|
+
assert.equal((await p.locator('#obChat .ob-fund li').count()), (await p.locator('#obChat .ob-fund li em').count()), 'every line names its source');
|
|
111
|
+
await shot(p, 'onboard-4-fundamentals');
|
|
112
|
+
await p.locator('#obChat [data-ob="f:goal"]').click();
|
|
113
|
+
await p.waitForFunction(() => /No goal file/.test(document.querySelector('#obChat .ob-fund').innerText));
|
|
114
|
+
assert(!fs.existsSync(path.join(root, 'pmos', 'planning', 'okrs', 'okrs-draft.md')), 'one press removes the draft');
|
|
115
|
+
await Promise.all([p.waitForURL(/onboarded=1&first=draft/, { timeout: 15000 }), press('first-yes')]);
|
|
116
|
+
await p.waitForSelector('#rvChat .rv-ticks'); await p.waitForSelector('#rvChat .rv-draft'); // B2: Home opens on the reveal, with the drafted initiative
|
|
101
117
|
await p.waitForFunction(() => /Checkout redesign/i.test(document.querySelector('#hmList').innerText), null, { timeout: 15000 });
|
|
102
|
-
assert(await p.locator('#obChat a.hm-chip').count() >= 3, 'a next action, not an empty success');
|
|
103
|
-
await shot(p, 'onboard-5-done');
|
|
104
118
|
const ph = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage();
|
|
105
|
-
await ph.goto(
|
|
119
|
+
await ph.goto(T.BASE + '/premium.html?onboard=1'); await ph.waitForSelector('#obChat .ob-fund'); await shot(ph, 'onboard-5-fundamentals-phone');
|
|
106
120
|
assert(await ph.locator('#obChat').isVisible()); await ph.close();
|
|
107
121
|
});
|
|
108
122
|
await step('The same folder again is reused as existing PMOS, never reinstalled', async () => {
|
|
@@ -112,17 +126,16 @@ const shot = async (p, name) => { if (SHOTS) { fs.mkdirSync(SHOTS, { recursive:
|
|
|
112
126
|
await fetch(T.BASE + '/api/onboard/reset', { method: 'POST', headers: h, body: '{}' });
|
|
113
127
|
const ph = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage();
|
|
114
128
|
await ph.goto(T.BASE + '/premium.html?onboard=1');
|
|
115
|
-
await ph.
|
|
116
|
-
await ph.
|
|
117
|
-
await ph.
|
|
118
|
-
await ph.
|
|
119
|
-
assert.match(await ph.locator('#obChat .ob-card').first().innerText(), /PMOS is already here/);
|
|
129
|
+
await ph.waitForSelector('#obChat .ob-good', { timeout: 15000 });
|
|
130
|
+
await say(ph, `Checkout redesign. The folder is ${root}`);
|
|
131
|
+
await ph.waitForSelector('#obChat [data-ob="plan-yes"]', { timeout: 15000 });
|
|
132
|
+
assert.match(await ph.locator('#obChat .ob-prop').last().innerText(), /PMOS is already here/);
|
|
120
133
|
await shot(ph, 'onboard-6-existing-pmos-phone');
|
|
121
|
-
await ph.locator('#obChat
|
|
122
|
-
await ph
|
|
123
|
-
await
|
|
124
|
-
await ph.
|
|
125
|
-
|
|
134
|
+
await ph.locator('#obChat [data-ob="plan-no"]').click();
|
|
135
|
+
await say(ph, 'no search');
|
|
136
|
+
await ph.waitForSelector('#obChat [data-ob="init"]');
|
|
137
|
+
await ph.locator('#obChat [data-ob="init"]').click();
|
|
138
|
+
await ph.waitForSelector('#obChat .ob-fund', { timeout: 60000 });
|
|
126
139
|
assert.equal(fs.readFileSync(path.join(root, 'pmos', 'AGENTS.md'), 'utf8'), '# edited by the PM');
|
|
127
140
|
await ph.close();
|
|
128
141
|
});
|
|
@@ -381,3 +381,6 @@
|
|
|
381
381
|
@media (max-width: 760px) { .ev-st [data-claude-setup] .wl-btn, .ev-st [data-claude-setup] select { min-height: 44px; } .ev-st [data-claude-setup] .cs-label { flex-wrap: wrap; } }
|
|
382
382
|
/* Settings › Claude › Your own rules (batch 26 item 2) */
|
|
383
383
|
.ev-st textarea.ev-rules { display: block; width: 100%; box-sizing: border-box; margin-top: 8px; min-height: 72px; padding: 10px 12px; border: 0; border-radius: 14px; background: var(--cz-well); color: var(--cz-ink); font: inherit; font-size: var(--fs-3); line-height: 1.45; resize: vertical; }
|
|
384
|
+
/* a quiet place (pm/quiet-place.js, onboarding B3 ObA4Gradual): one honest line instead of empty tables, until its moment comes */
|
|
385
|
+
.qp-on > :not(.qp-line) { display: none !important; }
|
|
386
|
+
.qp-line { margin: 18vh auto 0; max-width: 520px; padding: 0 16px; text-align: center; font-size: var(--fs-row); font-weight: var(--fw-strong); color: var(--cz-ink2); line-height: 1.5; }
|