create-pmos 0.188.0 → 0.189.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 (32) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/lib/headless.js +19 -5
  11. package/workspace/lib/pmos-verbs.js +2 -2
  12. package/workspace/lib/route-modules.js +1 -1
  13. package/workspace/lib/routes-boards.js +29 -12
  14. package/workspace/lib/routes-scan.js +224 -44
  15. package/workspace/lib/scan-cli.js +143 -45
  16. package/workspace/lib/scan-run.js +242 -87
  17. package/workspace/lib/scan.js +623 -79
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  21. package/workspace/public/pm/c3-initiatives.js +5 -4
  22. package/workspace/public/pm/c3-scan-api.js +167 -0
  23. package/workspace/public/pm/c3-scan-next.js +248 -0
  24. package/workspace/public/pm/c3-scan-setup.js +317 -0
  25. package/workspace/public/pm/c3-scan-words.js +34 -0
  26. package/workspace/public/pm/c3-scan.css +58 -9
  27. package/workspace/public/pm/c3-scan.js +36 -208
  28. package/workspace/public/pm/c3-toast.js +1 -0
  29. package/workspace/server.js +7 -4
  30. package/workspace/skills/horizon-scan-repo.skill +26 -10
  31. package/workspace/skills/horizon-scan-research.skill +44 -0
  32. package/workspace/skills/horizon-scan-web.skill +12 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.188.0",
3
+ "version": "0.189.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@856b541
1
+ kernel-source: pmos@372629f
2
2
  assembled: 2026-10-13
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-13T06:02:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T06:07:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-13T06:02:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T06:07:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-13T06:02:00Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T06:07:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -51,7 +51,7 @@ const TAB = '[data-rs-tab]';
51
51
  /* two reads of other initiatives (Horizon scan, Settings) have no demo answer: a 404 on them is not this suite's */
52
52
  const watch = (page) => {
53
53
  page.on('pageerror', (e) => errors.push('pageerror: ' + String((e && e.message) || e)));
54
- page.on('response', (r) => { if (r.status() >= 400 && !/\/api\/(scan\/today|settings)\b/.test(r.url())) errors.push('http ' + r.status() + ' ' + r.url()); });
54
+ page.on('response', (r) => { if (r.status() >= 400 && !/\/api\/(scan\/(?:today|portfolio)|settings)\b/.test(r.url())) errors.push('http ' + r.status() + ' ' + r.url()); });
55
55
  page.on('dialog', (d) => d.dismiss().catch(() => {}));
56
56
  };
57
57
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
@@ -3,9 +3,27 @@
3
3
  * Not a suite: it asserts nothing. The server runs with PMOS_DRIVER=off, so no scan and no suggest run starts: every suggestion, scan and
4
4
  * finding the page shows is a line this file wrote with lib/scan.js (the single writer) and the boards ledger.
5
5
  *
6
- * const F = await start({ name }) -> { BASE, ws, board, team, ledger, boards, post, get, seedScan, stop, S }
6
+ * const F = await start({ name }) -> { BASE, ws, board, team, ledger, boards, post, get, seedScan, portfolio, stop, S }
7
7
  * at start: the board holds three suggestions from the agent (a product, a domain, an approach) and no scan has run
8
8
  * seedScan() writes one finished scan of today: 2 web findings and 1 repository finding, each already an open Triage ask
9
+ *
10
+ * THE PORTFOLIO SCAN (initiative horizon-scan-portfolio). The page reads and presses /api/scan/portfolio, /report, /watch, /setting, /now, /stop
11
+ * and /proposal. Two ways to answer them, both over lib/scan.js:
12
+ * - the model (portfolioStub): the page's /api/scan/** calls are routed in the browser to a ledger of its own. The answers are the REAL view
13
+ * functions (lib/routes-scan.js portfolioAnswer and reportAnswer, over lib/scan.js fold) and the presses are the real lib/scan.js functions
14
+ * (watchOp, setSetting, selectScan, manualCheck, proposalChange, proposalDismiss, proposalUndo, proposalApprove), so every field is the
15
+ * server's. What it does not do is run a headless agent: a test moves the open scan with advance(). Create does not make a real initiative.
16
+ * - the real server (seedPortfolio): the server's own ledger seeded with the same lines, no routing, for the end-to-end section.
17
+ * The report documents are real markdown files in the scratch repository's report folder, read by the document sheet through GET /api/doc.
18
+ *
19
+ * const P = F.portfolio() a fresh model: 4 suggested watch items, cadence daily, no scan yet
20
+ * await P.install(page) route /api/scan/** of this page to the model; P.calls lists every request { method, path, body }
21
+ * P.advance() the open scan moves one step (web, repository, then the end: reports written, proposals filed)
22
+ * P.seed() a finished scan: the list kept, the reports written, the proposals filed
23
+ * P.research() the research scan of an approved research proposal ends; its follow-up document is written
24
+ * P.reportOf(type) the latest written top-level report of a type (id, path, title)
25
+ * P.fail = { path, status?, why?, error } the next request to that path answers { ok:false, error, why? } with that status
26
+ * const T = F.tools({ file, team, ws }) the ledger lines of a scan (suggest, begin, finish, seedFinished)
9
27
  */
10
28
  const fs = require('fs');
11
29
  const path = require('path');
@@ -20,7 +38,7 @@ async function call(base, method, p, body, headers) {
20
38
  return { status: r.status, body: j, raw };
21
39
  }
22
40
 
23
- async function start({ name = 'c3-scan' } = {}) {
41
+ async function start({ name = 'c3-scan', clean = false, guard = false } = {}) {
24
42
  const R = makeRepos(name);
25
43
  const team = codeRepo(path.join(R.base, 'team'));
26
44
  fs.mkdirSync(path.join(team, 'pmos', 'state'), { recursive: true });
@@ -29,8 +47,12 @@ async function start({ name = 'c3-scan' } = {}) {
29
47
  const ledger = path.join(team, 'pmos', 'state', 'scan.jsonl');
30
48
  const boards = path.join(team, 'pmos', 'state', 'boards.jsonl');
31
49
  const BASE = T.BASE;
50
+ // guard: a `claude` on the PATH that only records that it was called, for the end-to-end section. The server also runs with PMOS_DRIVER=off, so none is ever called.
51
+ const rec = path.join(R.base, 'claude-calls'); fs.mkdirSync(rec, { recursive: true });
52
+ const bin = path.join(R.base, 'bin'); fs.mkdirSync(bin, { recursive: true });
53
+ fs.writeFileSync(path.join(bin, 'claude'), `#!/bin/sh\ndate +%s%N > "${rec}/call.$$"\nprintf '%s\\n' "$@" >> "${rec}/call.$$"\necho '{"type":"result","subtype":"success","is_error":false,"result":"Done.","num_turns":1,"duration_ms":1,"permission_denials":[]}'\n`, { mode: 0o755 });
32
54
  const srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore',
33
- env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: team, PMOS_NO_OPEN: '1', PMOS_DRIVER: 'off' }) });
55
+ env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: team, PMOS_NO_OPEN: '1', PMOS_DRIVER: 'off', ...(guard ? { PATH: bin + path.delimiter + process.env.PATH } : {}) }) });
34
56
  await T.waitForServer(BASE);
35
57
  const press = await T.pressHeaders(BASE);
36
58
  const post = (p, body) => call(BASE, 'POST', p, body, press);
@@ -41,11 +63,13 @@ async function start({ name = 'c3-scan' } = {}) {
41
63
  if (made.status !== 200) throw new Error('could not create the initiative: ' + made.raw);
42
64
  const now = Date.now();
43
65
  const w = (line, at) => S.append(ledger, line, { now: at });
66
+ if (!clean) {
44
67
  w({ ev: 'scan-start', board: ws, scan: 'scan-0000000001', mode: 'suggest', watch: [] }, now - 3600000 * 30);
45
68
  w({ ev: 'watch-suggest', board: ws, id: 'w-00000001', kind: 'product', name: 'Rival Notes', why: 'A direct rival in partner onboarding', ini: 'scan-ui' }, now - 3600000 * 30);
46
69
  w({ ev: 'watch-suggest', board: ws, id: 'w-00000002', kind: 'domain', name: 'Data privacy law', why: 'We store partner emails', ini: 'scan-ui' }, now - 3600000 * 30);
47
70
  w({ ev: 'watch-suggest', board: ws, id: 'w-00000003', kind: 'approach', name: 'Eval-first delivery', why: 'How other teams write the test before the build', ini: 'scan-ui' }, now - 3600000 * 30);
48
71
  w({ ev: 'scan-end', board: ws, scan: 'scan-0000000001', ok: true, pages: 0, found: 3, skipped: 0, runs: 1 }, now - 3600000 * 30 + 60000);
72
+ }
49
73
 
50
74
  /** One finished scan of today, with its three findings as open Triage asks. */
51
75
  function seedScan() {
@@ -72,7 +96,225 @@ async function start({ name = 'c3-scan' } = {}) {
72
96
  w({ ev: 'scan-end', board: ws, scan, ok: true, pages: 12, found: 3, skipped: 1, runs: 2 }, t + 60000);
73
97
  return { scan, findings: F.map((f) => f.id), asks: F.map((f) => 'ask-' + f.id.slice(2)) };
74
98
  }
99
+ const portfolio = () => portfolioStub({ team, ws });
100
+ /** The real server's ledger, seeded for the end-to-end section: three suggestions, one of them kept and scanned in a finished scan of today (four reports, five proposals). */
101
+ function seedPortfolio() {
102
+ const L = tools({ file: ledger, team, ws });
103
+ const items = [
104
+ { id: 'w-00000021', kind: 'product', name: 'Stripe Connect', why: 'Partner onboarding looks like platform onboarding.', ini: 'scan-ui', boards: [ws] },
105
+ { id: 'w-00000022', kind: 'domain', name: 'API rate limits and quotas', why: 'Partners ask about limits.', ini: 'scan-ui', boards: [ws] },
106
+ { id: 'w-00000023', kind: 'approach', name: 'Self-serve sandbox keys', why: 'Partners wait days for access.', ini: 'scan-ui', boards: [] }];
107
+ L.suggest(items, now - 3600000 * 30);
108
+ const b = L.seedFinished(2, { items: [{ ...items[0], state: 'suggested' }], at: now - 600000, ini: 'scan-ui' });
109
+ return { items, scan: b.scan, reports: b.reports };
110
+ }
75
111
  const stop = () => { try { srv.kill('SIGKILL'); } catch { /* gone */ } T.killTmux(); R.cleanup(); };
76
- return { BASE, ws, board: ws, team, ledger, boards, post, get, seedScan, stop, T, S };
112
+ const calls = () => fs.readdirSync(rec).filter((f) => f.startsWith('call.'));
113
+ return { BASE, ws, board: ws, team, ledger, boards, post, get, seedScan, seedPortfolio, portfolio, calls, stop, T, S };
114
+ }
115
+
116
+ /* ── the ledger tools: every line below is written by lib/scan.js append (the single writer) and read back by its fold and view functions ── */
117
+ const AG = 'agent:sess-4.0a1b2c3d';
118
+ const PM = { by: 'pm:wawan' };
119
+ const hex = (n, w = 10) => n.toString(16).padStart(w, '0');
120
+ /** The agent's work as ledger lines: a finished portfolio scan with its report documents and proposals. `file` is the ledger, `team` the repository root. */
121
+ function tools({ file, team, ws }) {
122
+ const reports = S.reportsFolder(team);
123
+ const w = (line, at) => S.append(file, line, { now: at });
124
+ const day = (at) => require('./lib/driver.js').localDay(at);
125
+ const TYPES = ['benchmark', 'competitive', 'insight', 'productivity'];
126
+ const TITLE = { benchmark: 'Benchmark report: partner onboarding', competitive: 'Competitive analysis: identity and login', insight: 'Insight report: themes across boards', productivity: 'Productivity report: portfolio delivery' };
127
+ const idOf = (n, type, kind) => `${kind}-${hex(n, 8)}${hex(TYPES.indexOf(type) + 1, 2)}`;
128
+ const EV = (label, ref) => ({ label, ref });
129
+ /** What the repository run files for a report type: its sections, and its proposals. Proposals name the board of the portfolio and an initiative that exists. */
130
+ const FILED = (type, n, ini) => ({
131
+ benchmark: { sections: [
132
+ ['Summary', 'web', 'We compared partner onboarding at Stripe Connect and Twilio with ours on four points. We are behind on two.'],
133
+ ['Gaps', 'repo', '- Gap 1: 9 days to first API call (IDPF-1177).\n- Gap 2: no changelog. Partners ask support what changed (IDPF-1402).'],
134
+ ['Where we stand', 'repo', '- Level on rate-limit visibility since Tue 6 Oct.']],
135
+ proposals: [
136
+ { id: idOf(n, type, 'p'), kind: 'create', title: 'Publish a subscribable API changelog and change notices', why: 'All benchmarks publish a dated changelog. Four Jira tickets and one Slack thread here ask what changed.', finding: 'Gap 2: no changelog',
137
+ evidence: [EV('Stripe changelog', 'https://docs.stripe.com/changelog'), EV('Jira', 'IDPF-1402'), EV('Support thread', '#partner-support, 7 Oct')],
138
+ fields: { board: ws, lane: 'feature', anchor: 'health:tech', intent: 'Partners learn about API changes from one dated page and an email they can subscribe to.' } },
139
+ { id: idOf(n + 1000, type, 'p'), kind: 'research', title: 'Deep dive: rate-limit headers across 6 API vendors', why: 'The benchmark cannot say which header set partners expect.', finding: 'Where we stand: level on rate-limit visibility',
140
+ evidence: [EV('Stripe rate limits', 'https://docs.stripe.com/rate-limits'), EV('Jira', 'IDPF-1214'), EV('Options paper', 'docs/options/IDPF-07.md')],
141
+ fields: { question: 'Which rate-limit headers do 6 API vendors send, and which set should the Partner API adopt?', depth: 'standard' } },
142
+ { id: idOf(n + 2000, type, 'p'), kind: 'attach', title: `Attach the first-call benchmark to ${ini} as evidence`, why: 'The initiative targets 5 days to first API call. All benchmarks reach a first call the same day.', finding: 'Gap 1: 9 days to first API call',
143
+ evidence: [EV('Stripe testing', 'https://docs.stripe.com/connect/testing'), EV('DSU note', 'IDPF DSU, 8 Oct')], fields: { initiative: ini } }] },
144
+ competitive: { sections: [['Summary', 'web', 'Passkeys are the one gap that could cost deals.'], ['Openings', 'web', '- Opening: partner-centric accounts.']],
145
+ proposals: [{ id: idOf(n, type, 'p'), kind: 'create', title: 'Organisation accounts and SSO for partner admins', why: 'Cognito and Firebase leave organisation accounts to the customer.', finding: 'Opening: partner-centric accounts',
146
+ evidence: [EV('Auth0 organizations', 'https://auth0.com/docs/manage-users/organizations'), EV('Partner council', 'Partner council, 1 Oct')],
147
+ fields: { board: ws, lane: 'discovery', anchor: 'health:tech', intent: 'Find out whether partner admins need organisation accounts and SSO.' } }] },
148
+ insight: { sections: [['Summary', 'repo', 'Four themes repeat across boards.']], proposals: [] },
149
+ productivity: { sections: [['Summary', 'repo', 'Delivery slowed on IDPF. Most of it is one shared QA environment.']], proposals: [] },
150
+ })[type];
151
+ const planFor = (types, items, repo) => ({ web: items > 0 && types.some((t) => t !== 'productivity'), repo: !!repo });
152
+
153
+ /** A suggest run that filed the given watch items (each { id, kind, name, why, ini, boards }). */
154
+ function suggest(items, at) {
155
+ const scan = 'scan-' + hex(1);
156
+ w({ ev: 'scan-start', board: S.PORTFOLIO, scan, mode: 'suggest', watch: [], manual: false, repo: true, types: [], plan: { web: false, repo: true } }, at);
157
+ items.forEach((it, i) => w({ ev: 'watch-suggest', scan, boards: it.boards || [], id: it.id, kind: it.kind, name: it.name, why: it.why, ini: it.ini, by: AG }, at + 1000 * (i + 1)));
158
+ w({ ev: 'scan-end', board: S.PORTFOLIO, scan, ok: true, pages: 0, found: items.length, skipped: 0, runs: 1 }, at + 60000);
159
+ return scan;
160
+ }
161
+ /** The scan-start and report lines of a scan. → { scan, reports: [{ id, type, title, path }] } */
162
+ function begin(n, { types, items, repo, manual = false, at, research = null }) {
163
+ const scan = 'scan-' + hex(0xa0 + n, 10);
164
+ const select = { items: items.map((i) => i.id), repo, types };
165
+ w({ ev: 'scan-start', board: S.PORTFOLIO, scan, mode: research ? 'research' : 'scan', watch: items.map((i) => ({ id: i.id, kind: i.kind, name: i.name, why: i.why || '' })), manual, repo, types, plan: planFor(types, items.length, repo),
166
+ ...(manual ? { by: PM.by, select } : {}), ...(research ? { research } : {}) }, at);
167
+ const out = [];
168
+ const list = research ? ['insight'] : types;
169
+ list.forEach((type, i) => {
170
+ const id = idOf(n, type, 'r');
171
+ let name = day(at) + '.md'; let k = 2;
172
+ while (fs.existsSync(path.join(reports.abs, 'scan-' + type, name)) || out.some((o) => o.path.endsWith('/scan-' + type + '/' + name))) name = day(at) + '-' + (k++) + '.md';
173
+ const rel = `${reports.rel}/scan-${type}/${name}`;
174
+ const title = research ? 'Research: ' + String(research.question).slice(0, 80) : TITLE[type];
175
+ w({ ev: 'report', board: S.PORTFOLIO, scan, id, type, title, path: rel, origin: type === 'productivity' ? 'repo' : 'both', ...(research ? { parent: research.parent } : {}) }, at + 100 + i);
176
+ out.push({ id, type, title, path: rel });
177
+ });
178
+ return { scan, reports: out };
179
+ }
180
+ const runStart = (scan, origin, at) => w({ ev: 'run-start', board: S.PORTFOLIO, scan, run: 'srun-' + hex(origin === 'web' ? 1 : 2), origin }, at);
181
+ const runEnd = (scan, origin, at) => w({ ev: 'run-end', board: S.PORTFOLIO, scan, run: 'srun-' + hex(origin === 'web' ? 1 : 2), origin, ok: true }, at);
182
+ /** The sections and proposals the runs filed, the report files, and the scan-end line. */
183
+ function finish(n, b, { at, ini, pages = 31, skipped = 2, ok = true, reason = null, stopped = false } = {}) {
184
+ if (!stopped) {
185
+ for (const r of b.reports) {
186
+ if (b.research) break;
187
+ const f = FILED(r.type, n, ini);
188
+ f.sections.forEach(([heading, origin, body], i) => w({ ev: 'section', scan: b.scan, report: r.id, heading, body, origin, sources: origin === 'web' ? [{ label: 'Stripe', ref: 'https://docs.stripe.com/changelog' }] : [], by: AG }, at + 200 + i));
189
+ f.proposals.forEach((p, i) => w({ ev: 'proposal', scan: b.scan, report: r.id, ...p, by: AG }, at + 300 + i));
190
+ }
191
+ }
192
+ S.writeReports(file, b.scan, { now: at + 400, reports });
193
+ w({ ev: 'scan-end', board: S.PORTFOLIO, scan: b.scan, ok, ...(reason ? { reason } : {}), pages, found: stopped ? 0 : b.reports.length, skipped, runs: 2, reports: b.reports.length }, at + 500);
194
+ }
195
+ /** A whole finished scan in the ledger: the watch list kept, both runs, the reports and their proposals. */
196
+ function seedFinished(n, { items, at, ini, types = TYPES }) {
197
+ items.forEach((i) => { if (i.state !== 'kept') w({ ev: 'watch-keep', id: i.id, by: PM.by }, at - 60000); });
198
+ const b = begin(n, { types, items, repo: true, at });
199
+ runStart(b.scan, 'web', at + 10); runEnd(b.scan, 'web', at + 20); runStart(b.scan, 'repo', at + 30); runEnd(b.scan, 'repo', at + 40);
200
+ finish(n, b, { at: at + 50, ini });
201
+ return b;
202
+ }
203
+ return { reports, TYPES, TITLE, suggest, begin, runStart, runEnd, finish, seedFinished, w, day };
204
+ }
205
+
206
+ /* ── the portfolio model: a ledger of its own, read through lib/routes-scan.js (the answers) and written through lib/scan.js (the presses) ── */
207
+ function portfolioStub({ team, ws }) {
208
+ const RS = require('./lib/routes-scan.js');
209
+ const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'c3-scan-stub-'));
210
+ const file = path.join(dir, 'scan.jsonl');
211
+ const T0 = Date.now();
212
+ const L = tools({ file, team, ws });
213
+ const wnow = { id: ws, root: team };
214
+ const ctx = { scanner: { active: () => [] }, proSettings: () => null, scanClock: () => ({}) };
215
+ const M = { calls: [], fail: null, n: 1, cur: null, file, tools: L };
216
+ L.suggest([
217
+ { id: 'w-00000011', kind: 'product', name: 'Stripe Connect', why: 'Partner onboarding looks like platform onboarding. This is the usual reference.', ini: 'IDPF-12', boards: [ws] },
218
+ { id: 'w-00000012', kind: 'product', name: 'Twilio developer onboarding', why: 'They set the bar for sign-up to first API call.', ini: 'IDPF-12', boards: [ws] },
219
+ { id: 'w-00000013', kind: 'domain', name: 'API rate limits and quotas', why: 'Partners ask about limits.', ini: 'IDPF-07', boards: [ws] },
220
+ { id: 'w-00000014', kind: 'approach', name: 'Self-serve sandbox keys', why: 'Partners wait days for access.', ini: 'IDPF-12', boards: [ws] }], T0 - 3600000 * 30);
221
+ const state = () => S.fold(S.readLines(file));
222
+ M.seq = 0;
223
+ const tick = () => T0 - 300000 + (++M.seq) * 100; // each line a moment after the one before, all in the past
224
+ const view = () => ({ ok: true, ws, ledger: true, ...RS.portfolioAnswer(ctx, wnow, file, Date.now()) });
225
+ const open = () => S.openScans(state()).find((x) => x.mode !== 'suggest') || null;
226
+ const kept = () => S.keptItems(state(), null);
227
+ /** The open scan moves one step: web ends and the repository run starts, the repository run ends, then the scan finishes. */
228
+ function advance() {
229
+ const sc = open(); if (!sc || sc.mode === 'research') return;
230
+ const b = M.cur; const st = S.stepsOf(sc);
231
+ if (st[0].state === 'running') { L.runEnd(sc.scan, 'web', tick()); if (sc.plan.repo) L.runStart(sc.scan, 'repo', tick()); } else if (st[1].state === 'running') L.runEnd(sc.scan, 'repo', tick());
232
+ else finishScan(b);
233
+ }
234
+ function finishScan(b, o = {}) { L.finish(b.n, b, { at: tick(), ini: 'scan-ui', ...o }); M.cur = null; }
235
+ const press = {
236
+ watch(body) {
237
+ const r = S.watchOp(file, body.op, null, { id: body.id, name: body.name, kind: body.kind, why: body.why, restore: body.restore === true, ...(body.boards !== undefined ? { boards: body.boards } : {}) }, PM, { now: tick() });
238
+ return r.ok ? { status: 200, body: { ok: true, id: r.id, ...(r.item ? { item: r.item } : {}) } } : { status: r.status, body: { ok: false, error: r.error } };
239
+ },
240
+ setting(body) {
241
+ const r = S.setSetting(file, { cadence: body.cadence, repo: body.repo, types: body.types }, PM, { now: tick() });
242
+ return r.ok ? { status: 200, body: { ok: true, setting: r.setting, schedule: S.scheduleText(r.setting.cadence) } } : { status: r.status, body: { ok: false, error: r.error } };
243
+ },
244
+ now(body) {
245
+ const st = state();
246
+ const chk = S.manualCheck(st, Date.now(), { quiet: false, running: false });
247
+ if (!chk.ok) return { status: chk.status, body: { ok: false, error: chk.error, why: chk.why, used: chk.used, left: chk.left } };
248
+ const sel = S.selectScan(st, { items: body.items, repo: body.repo, types: body.types });
249
+ if (!sel.ok) return { status: sel.status, body: { ok: false, error: sel.error } };
250
+ const at = tick();
251
+ const b = L.begin(++M.n, { types: sel.types, items: sel.items, repo: sel.repo, manual: true, at });
252
+ b.n = M.n; M.cur = b;
253
+ if (sel.plan.web) L.runStart(b.scan, 'web', tick()); else L.runStart(b.scan, 'repo', tick());
254
+ const sc = state().scans.get(b.scan);
255
+ return { status: 200, body: { ok: true, scan: b.scan, reports: b.reports, steps: S.stepsOf(sc), left: chk.left - 1 } };
256
+ },
257
+ stop() {
258
+ const sc = open(); if (!sc) return { status: 409, body: { ok: false, error: 'No scan is running.' } };
259
+ S.append(file, { ev: 'scan-stop', board: sc.board, scan: sc.scan, by: PM.by }, { now: tick() });
260
+ if (M.cur && M.cur.scan === sc.scan) finishScan(M.cur, { ok: false, reason: 'stopped by the PM', stopped: true }); else L.w({ ev: 'scan-end', board: sc.board, scan: sc.scan, ok: false, reason: 'stopped by the PM', pages: 0, found: 0, skipped: 0, runs: 1 }, tick());
261
+ return { status: 200, body: { ok: true, scans: [sc.scan] } };
262
+ },
263
+ proposal(body) {
264
+ const st = state(); const p = st.proposals.get(body.id);
265
+ const view1 = () => { const s2 = state(); return RS.reportAnswer(ctx, wnow, file, s2.proposals.get(body.id).report, Date.now()).body.proposals.find((x) => x.id === body.id); };
266
+ if (!p) return { status: 404, body: { ok: false, error: 'no proposal ' + body.id } };
267
+ if (body.action === 'undo') { const r = S.proposalUndo(file, body.id, PM, { now: tick() }); return r.ok ? { status: 200, body: { ok: true, proposal: view1() } } : { status: r.status, body: { ok: false, error: r.error } }; }
268
+ if (p.state !== 'open') return { status: 409, body: { ok: false, error: `the proposal is ${p.state} already` } };
269
+ if (body.action === 'dismiss') { const r = S.proposalDismiss(file, body.id, body.why, PM, { now: tick() }); return r.ok ? { status: 200, body: { ok: true, proposal: view1() } } : { status: r.status, body: { ok: false, error: r.error } }; }
270
+ if (body.fields !== undefined || body.action === 'change') {
271
+ const r = S.proposalChange(file, body.id, body.fields, PM, { now: tick() });
272
+ if (!r.ok) return { status: r.status, body: { ok: false, error: r.error } };
273
+ if (body.action === 'change') return { status: 200, body: { ok: true, proposal: view1() } };
274
+ }
275
+ const q = state().proposals.get(body.id);
276
+ if (q.kind === 'create') {
277
+ const id = 'idpf-' + (97 + (++M.made)); const result = { initiative: id, path: 'planning/initiatives/' + id + '.md', board: q.fields.board, stage: 'Discover' };
278
+ S.proposalApprove(file, body.id, result, PM, { now: tick() }); S.addEvidence(file, { initiative: id, report: q.report, proposal: body.id, note: q.finding }, PM, { now: tick() });
279
+ return { status: 200, body: { ok: true, proposal: view1(), result: { initiative: { id, path: result.path }, board: result.board, stage: result.stage } } };
280
+ }
281
+ if (q.kind === 'attach') { S.proposalApprove(file, body.id, { initiative: q.fields.initiative }, PM, { now: tick() }); S.addEvidence(file, { initiative: q.fields.initiative, report: q.report, proposal: body.id, note: q.finding }, PM, { now: tick() }); return { status: 200, body: { ok: true, proposal: view1(), result: { initiative: q.fields.initiative } } }; }
282
+ // research: a follow-up scan whose one report is linked to the report the proposal came from
283
+ const b = L.begin(++M.n, { types: ['insight'], items: [], repo: true, at: tick(), research: { question: q.fields.question, depth: q.fields.depth, sources: [], parent: q.report, proposal: q.id } });
284
+ b.n = M.n; b.research = true; M.follow = b; L.runStart(b.scan, 'web', tick());
285
+ S.proposalApprove(file, body.id, { scan: b.scan, report: b.reports[0].id, parent: q.report }, PM, { now: tick() });
286
+ return { status: 200, body: { ok: true, proposal: view1(), result: { scan: b.scan, report: b.reports[0].id, parent: q.report, steps: S.stepsOf(state().scans.get(b.scan)) } } };
287
+ },
288
+ };
289
+ M.made = 0;
290
+ const j = (route, status, body) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
291
+ async function handle(route, request) {
292
+ const u = new URL(request.url()); const p = u.pathname; const method = request.method();
293
+ let body = {}; try { body = JSON.parse(request.postData() || '{}'); } catch { /* none */ }
294
+ M.calls.push({ method, path: p, body, query: Object.fromEntries(u.searchParams) });
295
+ if (M.fail && M.fail.path === p) { const f = M.fail; M.fail = null; return j(route, f.status || 200, { ok: false, error: f.error, ...(f.why ? { why: f.why } : {}) }); }
296
+ if (method === 'GET' && p === '/api/scan/portfolio') return j(route, 200, view());
297
+ if (method === 'GET' && p === '/api/scan/report') { const r = RS.reportAnswer(ctx, wnow, file, u.searchParams.get('id') || '', Date.now()); return j(route, r.status, r.body); }
298
+ if (method !== 'POST') return route.fallback();
299
+ const verb = p.replace('/api/scan/', '');
300
+ if (press[verb]) { const r = press[verb](body); return j(route, r.status, r.body); }
301
+ return j(route, 404, { ok: false, error: 'no such route' });
302
+ }
303
+ return Object.assign(M, {
304
+ view, advance, state,
305
+ /** The first scan, finished: the watch list kept, both runs, the reports written and the proposals filed. */
306
+ seed() { const st = state(); kept(); const items = S.itemsOf(st, null).map((i) => ({ ...i })); const b = L.seedFinished(++M.n, { items, at: tick(), ini: 'scan-ui' }); b.n = M.n; return b; },
307
+ /** The research scan of an approved research proposal ends: its follow-up document is written, linked to its parent. */
308
+ research() {
309
+ const b = M.follow; const at = tick();
310
+ L.w({ ev: 'section', scan: b.scan, report: b.reports[0].id, heading: 'Question', body: 'Which headers?', origin: 'web', sources: [{ label: 'Stripe', ref: 'https://docs.stripe.com/rate-limits' }], by: AG }, at);
311
+ L.runEnd(b.scan, 'web', at + 1); L.runStart(b.scan, 'repo', at + 2); L.runEnd(b.scan, 'repo', at + 3);
312
+ L.finish(b.n, b, { at: at + 10, ini: 'scan-ui' });
313
+ return b.reports[0];
314
+ },
315
+ reportOf: (type) => [...state().reports.values()].filter((r) => r.type === type && !r.parent && r.written).pop(),
316
+ async install(page) { await page.route('**/api/scan/**', handle); },
317
+ close() { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* gone */ } },
318
+ });
77
319
  }
78
- module.exports = { start };
320
+ module.exports = { start, tools };