create-pmos 0.53.1 → 0.53.2

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 (45) 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/REMOTE.md +87 -6
  7. package/workspace/first-paint-audit.js +6 -2
  8. package/workspace/fixtures/initiatives-api/list.json +13 -0
  9. package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
  10. package/workspace/fixtures/report-api/draft.json +105 -0
  11. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
  12. package/workspace/guide/index.md +1 -0
  13. package/workspace/guide/initiatives-roadmap.md +1 -0
  14. package/workspace/guide/initiatives-screen.md +1 -0
  15. package/workspace/guide/layout.md +3 -2
  16. package/workspace/guide/report-slides.md +28 -0
  17. package/workspace/guide/reports.md +2 -0
  18. package/workspace/lib/auto-sync.js +83 -0
  19. package/workspace/lib/remote-access.js +96 -16
  20. package/workspace/lib/report-draft.js +33 -4
  21. package/workspace/lib/report-slides-data.js +141 -0
  22. package/workspace/lib/routes-initiatives.js +35 -2
  23. package/workspace/lib/routes-reports.js +3 -3
  24. package/workspace/lib/sync.js +61 -6
  25. package/workspace/package-lock.json +1 -1
  26. package/workspace/package.json +1 -1
  27. package/workspace/public/pm/app.js +16 -1
  28. package/workspace/public/pm/components.css +783 -0
  29. package/workspace/public/pm/dest-reports.js +8 -2
  30. package/workspace/public/pm/initiative-list.js +18 -1
  31. package/workspace/public/pm/initiatives-copy.js +4 -0
  32. package/workspace/public/pm/print.css +11 -0
  33. package/workspace/public/pm/report-slides.js +438 -0
  34. package/workspace/public/pm/report-view.js +8 -2
  35. package/workspace/public/pm/reports-copy.js +3 -0
  36. package/workspace/public/pm/roadmap.js +23 -0
  37. package/workspace/public/pm/slides-copy.js +52 -0
  38. package/workspace/public/pm/tokens.css +13 -0
  39. package/workspace/public/pm/topbar.js +5 -2
  40. package/workspace/replaced-tests.json +23 -1
  41. package/workspace/server.js +33 -13
  42. package/workspace/skills/report-biweekly.skill +1 -0
  43. package/workspace/test-files.json +2 -0
  44. package/workspace/test-lists/run6.json +9 -0
  45. package/workspace/test-lists/workspace-public-host.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.53.1",
3
+ "version": "0.53.2",
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@bfd9b34
1
+ kernel-source: pmos@439620f
2
2
  assembled: 2026-10-06
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-06T01:59:33Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T02:40:00Z"}
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-06T01:59:33Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T02:40:00Z"}
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-06T01:59:33Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T02:40:00Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -6,7 +6,7 @@ timestamp: 2026-10-06
6
6
 
7
7
  # Reaching the workspace from your phone — over a private tailnet, never the public internet
8
8
 
9
- The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it runs on. This runbook enables reaching **the same local server** from another of your own devices — a phone, a laptop — over a **private Tailscale tailnet**. It never exposes a public endpoint.
9
+ The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it runs on. This runbook enables reaching **the same local server** from another of your own devices — a phone, a laptop — over a **private Tailscale tailnet**. The tailnet path never exposes a public endpoint. To use your own domain instead, see [Reaching the workspace at your own domain](#reaching-the-workspace-at-your-own-domain-cloudflare-tunnel--access): the server still binds loopback only, and Cloudflare forwards to it.
10
10
 
11
11
  ## Why a tailnet, and why no public endpoint (D62)
12
12
 
@@ -67,7 +67,7 @@ A public server that owns a PTY running your Claude subscription is severe expos
67
67
  ## Security model (what is guaranteed)
68
68
 
69
69
  - **Never public.** The only addresses the server will ever bind are loopback and a `100.64.0.0/10` tailnet address. `0.0.0.0`, `::`, LAN, and public IPs are refused — the server exits with a clear error rather than binding them. There is no code path to a public bind.
70
- - **Token beyond loopback.** With remote mode on, every non-loopback HTTP request and WebSocket upgrade must present the bearer token, or it is rejected (`401` / socket closed). A browser page load gets the sign-in page with that `401`. The one exception is `POST /auth`, the sign-in form's target: it only compares the posted token and, on a match, sets the session cookie. It still sits behind the Origin/Host allowlist. Loopback requests stay token-free for local development. The comparison is constant-time.
70
+ - **Token beyond loopback.** With remote mode on, every non-loopback HTTP request and WebSocket upgrade must present the bearer token, or it is rejected (`401` / socket closed). A browser page load gets the sign-in page with that `401`. The one exception is `POST /auth`, the sign-in form's target: it only compares the posted token and, on a match, sets the session cookie. It still sits behind the Origin/Host allowlist. Loopback requests stay token-free for local development, unless they arrived through a proxy (see [Why a proxy on the same machine needs special handling](#why-a-proxy-on-the-same-machine-needs-special-handling)). The comparison is constant-time.
71
71
  - **Origin/Host allowlist (D64).** Both the HTTP guard and the WebSocket-upgrade handshake reject any request whose `Origin` is foreign (not same-origin with the `Host` it arrived on) or whose `Host` hostname is not loopback/localhost or your tailnet address. This closes the cross-origin-localhost / DNS-rebinding vector: without it, **any website open in your browser** could open a WebSocket to `ws://127.0.0.1:<port>` and spawn a shell + type into it, because loopback peers are token-exempt. A same-origin page served by the workspace passes; a cross-site page is rejected `403`. Native clients / `curl` (which send no `Origin`) still pass this check and are then gated by the token.
72
72
  - **No header/token logging (D08).** The request-guard path logs nothing about the request. The token is printed at most once at startup (only when generated); a token you supply is never echoed.
73
73
  - **Rate-limited control channel (D64).** The WS messages that spawn shells (`new`/`respawn`) and feed a PTY (`input`) are per-connection rate-limited, bounding abuse if the socket is ever reached.
@@ -77,14 +77,95 @@ A public server that owns a PTY running your Claude subscription is severe expos
77
77
 
78
78
  The workspace is **tailnet-first**. The tailnet path above is the supported way to reach it from another of your own devices and needs no internet exposure. If you nonetheless expose an instance to the internet, D64 permits it **only after all three of the following are in place — never before, and never as a raw public bind**:
79
79
 
80
- 1. **The bearer token for every non-loopback peer** (already enforced in remote mode).
81
- 2. **The Origin/Host allowlist** (already enforced — see above).
82
- 3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It speaks plain HTTP/WS and must sit behind something that terminates TLS for it — Tailscale's own HTTPS (`tailscale serve` / `tailscale funnel`) or a reverse proxy (Caddy, nginx, a tunnel) that holds the certificate and forwards to the loopback/tailnet listener. Do **not** put the raw HTTP port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this stays a conscious proxy decision, not an accident.
80
+ 1. **The bearer token for every non-loopback or proxied request.** A request that arrives through a reverse proxy or tunnel is never token-exempt, even though the proxy connects from `127.0.0.1` (see below).
81
+ 2. **The Origin/Host allowlist** (already enforced — see above). `PMOS_PUBLIC_HOST` adds exactly one public hostname to it.
82
+ 3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It speaks plain HTTP/WS and must sit behind something that terminates TLS for it. Do **not** put the raw HTTP port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this stays a conscious proxy decision, not an accident.
83
83
 
84
- When a reverse proxy forwards to the workspace, keep the forwarded `Host` set to the loopback/tailnet host the server actually binds, so the Origin/Host allowlist still recognises same-origin requests.
84
+ **Do not rewrite the forwarded `Host` header to a loopback address.** Earlier versions of this runbook advised it. Forward the public hostname unchanged and set `PMOS_PUBLIC_HOST` to it.
85
+
86
+ ### Why a proxy on the same machine needs special handling
87
+
88
+ A tunnel or reverse proxy that runs on the workspace machine (cloudflared, Caddy, nginx) connects to the server from `127.0.0.1`. Loopback peers are token-exempt, so without a further check every internet request would look local. The server therefore treats a request as **proxied** when it carries any of these headers, and a proxied request must always present the token:
89
+
90
+ `Forwarded`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP`, `Cf-Connecting-IP`, `Cf-Ray`, `Cf-Access-Jwt-Assertion`
91
+
92
+ A request to the `PMOS_PUBLIC_HOST` hostname is proxied by definition, with or without these headers.
93
+
94
+ ## Reaching the workspace at your own domain (Cloudflare Tunnel + Access)
95
+
96
+ This runbook puts the workspace at `https://pm-os.app/` (use your own hostname). Cloudflare holds the certificate and terminates TLS. **Cloudflare Access** asks for your Google or email login before any request reaches the machine. The workspace then asks for its own token. The workspace keeps running on **your own always-on machine**; Cloudflare forwards bytes to it. It does not run on Cloudflare Workers or Containers: it holds real terminal sessions, a git checkout and your agent CLI's login, and needs a persistent disk.
97
+
98
+ Two locks protect the workspace, and each one is enforced by the server:
99
+
100
+ | Lock | Who checks it | What happens without it |
101
+ |---|---|---|
102
+ | Cloudflare Access login | Cloudflare's edge. The server refuses a request to the public host that has no `Cf-Access-Jwt-Assertion` header. | `403`, before the sign-in page. |
103
+ | The workspace token | The server, constant-time. | `401` and the sign-in page. |
104
+
105
+ The Access check in the server is a **presence check**: it confirms that Access is in front, and it fails closed when you forget the Access application. It does not verify the assertion's signature, because that needs Cloudflare's signing keys fetched over the network, and the workspace makes no outbound requests (D79). The origin listens on loopback only, so the tunnel is the only way to reach it with the public hostname.
106
+
107
+ ### Setup
108
+
109
+ 1. **Pick the machine.** Use a machine that stays on: your desktop, or a small Linux VPS. Install the workspace there as usual, and sign in to your agent CLI in a workspace terminal.
110
+
111
+ 2. **Move the domain's DNS to this tunnel.** In the Cloudflare dashboard, remove any existing record or Pages/Worker route for the hostname that you want to use. For `pm-os.app`, the retired PM-OS Pages project and Worker still own it.
112
+
113
+ 3. **Create the Access application first.** In Cloudflare Zero Trust, go to **Access › Applications › Add an application › Self-hosted**. Set the domain to `pm-os.app`. Add one policy: **Allow**, **Emails**, your own email address. Save. Do this before the tunnel, so the hostname is never reachable without a login.
114
+
115
+ 4. **Create the tunnel.** Install `cloudflared` on the machine, then:
116
+
117
+ ```sh
118
+ cloudflared tunnel login
119
+ cloudflared tunnel create pmos
120
+ cloudflared tunnel route dns pmos pm-os.app
121
+ ```
122
+
123
+ Write `~/.cloudflared/config.yml`:
124
+
125
+ ```yaml
126
+ tunnel: pmos
127
+ credentials-file: /home/<you>/.cloudflared/<tunnel-id>.json
128
+ ingress:
129
+ - hostname: pm-os.app
130
+ service: http://127.0.0.1:7433
131
+ - service: http_status:404
132
+ ```
133
+
134
+ Do not set `httpHostHeader`. The server must see the `Host: pm-os.app` that the browser sent.
135
+
136
+ 5. **Start the workspace in public-host mode**, from `workspace/`:
137
+
138
+ ```sh
139
+ PMOS_PUBLIC_HOST=pm-os.app PMOS_TOKEN='<a long random secret>' npm start
140
+ ```
141
+
142
+ Supply your own `PMOS_TOKEN` so that a restart does not sign out every device. Generate one with `openssl rand -base64 32`. Without `PMOS_TOKEN`, the server generates a token and prints it once.
143
+
144
+ 6. **Start the tunnel:** `cloudflared tunnel run pmos`. To keep it running after a reboot, install it as a service (`sudo cloudflared service install`).
145
+
146
+ 7. **Open `https://pm-os.app/`.** Cloudflare asks for your login. The workspace then shows its sign-in page. Paste the token. The workspace sets a `Secure` session cookie for 7 days.
147
+
148
+ ### Check that it is closed
149
+
150
+ Run these from a machine that is **not** signed in to Access:
151
+
152
+ - `curl -sI https://pm-os.app/` returns a redirect to `*.cloudflareaccess.com`, not a workspace page.
153
+ - On the workspace machine, `curl -s -H 'Host: pm-os.app' http://127.0.0.1:7433/api/sessions` returns `403` (no Access assertion).
154
+ - On the workspace machine, `curl -s -H 'X-Forwarded-For: 1.2.3.4' http://127.0.0.1:7433/api/sessions` returns `401` (proxied, no token).
155
+
156
+ ### Configuration
157
+
158
+ | Variable | Effect |
159
+ |---|---|
160
+ | `PMOS_PUBLIC_HOST=<hostname>` | Opt in. Add this one bare hostname to the Host allowlist, and require the token and the Access header for requests to it. The server still binds loopback only (plus the tailnet when `PMOS_REMOTE=tailnet` is also set). A scheme, port, path, IP address or `localhost` is refused, and the server does not start. |
161
+ | `PMOS_TOKEN=<t>` | The workspace token. Recommended in this mode. Never printed. |
85
162
 
86
163
  ## Troubleshooting
87
164
 
165
+ - **`403` "the public host is only reachable through Cloudflare Access"** — the request reached the workspace without passing Access. Create the Access application for the hostname (Setup step 3).
166
+ - **`403` "forbidden origin/host" on the public hostname** — `PMOS_PUBLIC_HOST` is not set, or it names another hostname, or the tunnel rewrites the `Host` header. Remove `httpHostHeader` from the tunnel's ingress rule.
167
+ - **A local reverse proxy now gets `401`** — it adds a forwarding header, so its requests need the token. This is intended.
168
+
88
169
  - **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"** — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real `tailscale ip -4` value.
89
170
  - **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run `tailscale up`, or set `PMOS_TAILNET_IP`.
90
171
  - **"cannot bind 100.x.y.z:PORT — that address is not present on this machine"** — the pinned `PMOS_TAILNET_IP` isn't actually assigned to this machine. Check `tailscale ip -4`.
@@ -7,6 +7,7 @@
7
7
  * [draggable=true] inside the viewport on first paint.
8
8
  *
9
9
  * EXPECT PRD Q5's budgets at 1280×800, counted outside the dock and the terminal
10
+ * SPECIMEN_A specimen A's own counts (2026-10-05), the calibration the audit must reproduce
10
11
  * audit(phone) shoot.js's audit, verbatim; runs in the page (page.evaluate(audit, false))
11
12
  * countIn([scope, exclude]) the same selector and visibility test, inside `scope` and outside `exclude`; in the page
12
13
  * stableCount([scope, exclude, minus]) countIn once the screen is still (two animation frames apart); in the page,
@@ -16,7 +17,10 @@
16
17
  * Do not tune the selector or the visibility test for one surface: change them here, for all, or not at all.
17
18
  */
18
19
 
19
- const EXPECT = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
20
+ // The budgets start at specimen A's counts. reports: 14 → 15 in Run 6 for Present, the one control the PM asked to keep
21
+ // with the slides (2026-10-06). The calibration stays specimen A's own count: the specimen did not change.
22
+ const SPECIMEN_A = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
23
+ const EXPECT = Object.freeze({ ...SPECIMEN_A, reports: 15 });
20
24
 
21
25
  /* ── shoot.js audit(), copied verbatim (scratchpad/specimens2/shoot.js, 2026-10-05) ─────────────────────────────── */
22
26
  // runs in the page
@@ -142,4 +146,4 @@ function installCounter(page) {
142
146
  return page.addInitScript({ content: 'window.__countIn = ' + countIn.toString() + ';' });
143
147
  }
144
148
 
145
- module.exports = { EXPECT, audit, countIn, stableCount, installCounter };
149
+ module.exports = { EXPECT, SPECIMEN_A, audit, countIn, stableCount, installCounter };
@@ -172,5 +172,18 @@
172
172
  "init": null,
173
173
  "why": "line 9: not JSON"
174
174
  }
175
+ ],
176
+ "unrecorded": [
177
+ {
178
+ "id": "fx-orphan",
179
+ "title": "An initiative with a PRD and no record",
180
+ "artifacts": [
181
+ {
182
+ "name": "fx-orphan-prd.md",
183
+ "path": "planning/prd/fx-orphan-prd.md",
184
+ "role": "PRD"
185
+ }
186
+ ]
187
+ }
175
188
  ]
176
189
  }
@@ -204,5 +204,18 @@
204
204
  "init": null,
205
205
  "why": "line 9: not JSON"
206
206
  }
207
+ ],
208
+ "unrecorded": [
209
+ {
210
+ "id": "fx-orphan",
211
+ "title": "An initiative with a PRD and no record",
212
+ "artifacts": [
213
+ {
214
+ "name": "fx-orphan-prd.md",
215
+ "path": "planning/prd/fx-orphan-prd.md",
216
+ "role": "PRD"
217
+ }
218
+ ]
219
+ }
207
220
  ]
208
221
  }
@@ -173,5 +173,110 @@
173
173
  "path": "planning/reports/definitions/biweekly.md",
174
174
  "audience": "leadership"
175
175
  }
176
+ },
177
+ "slides": {
178
+ "v": 1,
179
+ "meta": {
180
+ "product": "northwind",
181
+ "cadence": "Biweekly",
182
+ "slot": "2026-10-16",
183
+ "today": "2026-10-16",
184
+ "last": {
185
+ "from": "2026-10-02",
186
+ "to": "2026-10-16"
187
+ },
188
+ "next": {
189
+ "from": "2026-10-16",
190
+ "to": "2026-10-30"
191
+ }
192
+ },
193
+ "roadmap": {
194
+ "months": [
195
+ "2026-09",
196
+ "2026-10",
197
+ "2026-11",
198
+ "2026-12",
199
+ "2027-01",
200
+ "2027-02"
201
+ ],
202
+ "today": "2026-10-16",
203
+ "groups": [
204
+ {
205
+ "name": "Now",
206
+ "rows": [
207
+ {
208
+ "id": "partner-sso",
209
+ "title": "Partner SSO",
210
+ "estimated": false,
211
+ "segments": [
212
+ {
213
+ "stage": "build",
214
+ "label": "Build",
215
+ "start": "2026-10-01",
216
+ "due": "2026-10-24"
217
+ }
218
+ ],
219
+ "milestones": [
220
+ {
221
+ "title": "Pilot live",
222
+ "due": "2026-10-24",
223
+ "done": false
224
+ }
225
+ ]
226
+ }
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ "list": [
232
+ {
233
+ "id": "partner-sso",
234
+ "title": "Partner SSO",
235
+ "status": "Build",
236
+ "pct": 58,
237
+ "next_milestone": {
238
+ "title": "Pilot live",
239
+ "due": "2026-10-24",
240
+ "overdue": false
241
+ },
242
+ "release": "2026-11-20"
243
+ }
244
+ ],
245
+ "initiatives": [
246
+ {
247
+ "id": "partner-sso",
248
+ "title": "Partner SSO",
249
+ "status": "Build",
250
+ "pct": 58,
251
+ "done": 7,
252
+ "total": 12,
253
+ "next_milestone": {
254
+ "title": "Pilot live",
255
+ "due": "2026-10-24",
256
+ "overdue": false
257
+ },
258
+ "release": "2026-11-20",
259
+ "last": [
260
+ {
261
+ "title": "SAML metadata",
262
+ "stage": "Build",
263
+ "owner": "Sheryl",
264
+ "milestone": false,
265
+ "done": true,
266
+ "date": null
267
+ }
268
+ ],
269
+ "next": [
270
+ {
271
+ "title": "Pilot live",
272
+ "stage": "Build",
273
+ "owner": null,
274
+ "milestone": true,
275
+ "done": false,
276
+ "date": "2026-10-24"
277
+ }
278
+ ]
279
+ }
280
+ ]
176
281
  }
177
282
  }
@@ -73,6 +73,16 @@ Stage: Build · On track · 7 of 12 (+4)
73
73
 
74
74
  - Billing v2 (billing-v2), 8 Oct
75
75
 
76
+ **Changelog** (your release notes for the changelog slide)
77
+
78
+ <!-- pmos:note report.changelog -->
79
+ Launch · Exports: Audit log CSV export
80
+
81
+ Admins can download the audit log as a CSV file.
82
+
83
+ - Added: CSV writer for the audit log
84
+ <!-- /pmos:note -->
85
+
76
86
  ## Decided this cycle
77
87
 
78
88
  - Audit log export moved to Now, 3 Oct (planning/runs/placements.jsonl)
@@ -95,3 +105,5 @@ For leadership only.
95
105
  **Not reached**
96
106
 
97
107
  - tracker: not refreshed since 2 Oct
108
+
109
+ <!-- pmos:slides v1 {"v":1,"meta":{"product":"northwind","cadence":"Biweekly","slot":"2026-10-16","today":"2026-10-16","last":{"from":"2026-10-02","to":"2026-10-16"},"next":{"from":"2026-10-16","to":"2026-10-30"}},"roadmap":{"months":["2026-09","2026-10","2026-11","2026-12","2027-01","2027-02"],"today":"2026-10-16","groups":[{"name":"Now","rows":[{"id":"audit-log","title":"Audit log export","estimated":false,"segments":[{"stage":"spec","label":"Spec","start":"2026-09-14","due":"2026-10-03"},{"stage":"build","label":"Build","start":"2026-10-04","due":"2026-10-28"},{"stage":"evaluate","label":"Evaluate","start":"2026-10-29","due":"2026-11-12"}],"milestones":[{"title":"Release","due":"2026-11-12","done":false}]},{"id":"partner-sso","title":"Partner SSO","estimated":false,"segments":[{"stage":"build","label":"Build","start":"2026-10-01","due":"2026-10-24"}],"milestones":[{"title":"Pilot live","due":"2026-10-24","done":false}]}]},{"name":"Next","rows":[{"id":"billing-v2","title":"Billing v2","estimated":true,"segments":[{"stage":"intake","label":"Intake","start":"2026-11-02","due":"2026-11-20"},{"stage":"spec","label":"Spec","start":"2026-11-23","due":"2026-12-18"}],"milestones":[]}]}]},"list":[{"id":"audit-log","title":"Audit log export","status":"Evaluate","pct":60,"next_milestone":{"title":"Retention rule","due":"2026-10-10","overdue":true},"release":"2026-11-12"},{"id":"partner-sso","title":"Partner SSO","status":"Build","pct":58,"next_milestone":{"title":"Pilot live","due":"2026-10-24","overdue":false},"release":null}],"initiatives":[{"id":"audit-log","title":"Audit log export","status":"Evaluate","pct":60,"done":3,"total":5,"next_milestone":{"title":"Retention rule","due":"2026-10-10","overdue":true},"release":"2026-11-12","last":[{"title":"CSV writer","stage":"Build","owner":"Sheryl","milestone":false,"done":true,"date":null},{"title":"Retention rule","stage":"Build","owner":"Ricky","milestone":true,"done":false,"date":"2026-10-10"}],"next":[{"title":"Retention rule","stage":"Build","owner":"Ricky","milestone":true,"done":false,"date":"2026-10-10"},{"title":"Export UI","stage":"Build","owner":"Lasantha","milestone":false,"done":false,"date":null}]},{"id":"partner-sso","title":"Partner SSO","status":"Build","pct":58,"done":7,"total":12,"next_milestone":{"title":"Pilot live","due":"2026-10-24","overdue":false},"release":null,"last":[],"next":[{"title":"Pilot live","stage":"Build","owner":null,"milestone":true,"done":false,"date":"2026-10-24"},{"title":"Pilot partners on SSO","stage":"Build","owner":"Agent","milestone":false,"done":false,"date":null}]}]} -->
@@ -51,3 +51,4 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
51
51
  - [Initiatives › Timeline](initiatives-timeline.md): when each initiative runs, one row per initiative, and how to reorder rows by their grip.
52
52
  - [Board](board.md): one Kanban for the items of every initiative, its filters, Release, and `pmos board`.
53
53
  - [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
54
+ - [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
@@ -29,6 +29,7 @@ The first time you open the Roadmap, the file has no lines yet. The workspace th
29
29
 
30
30
  - An initiative with activity in the last 5 days reads **Now, unconfirmed**. Move it to confirm it, or move it to Next or Later.
31
31
  - An initiative with no activity for 45 days reads **dormant**, at the end of Later.
32
+ - Documents with no initiative record (a PRD and rubric, or a prototype folder) are listed after dormant, under **with no record**. They are not placed.
32
33
  - Every other initiative is in Later, the most recent activity first.
33
34
  - An accepted or stopped initiative is never in Now. It goes to the end of Later.
34
35
 
@@ -30,6 +30,7 @@ Only you can move an initiative into Now. The workspace writes the line as yours
30
30
  - **unconfirmed.** An initiative that reads Now only because it is active, with no line from you. Confirm it in the Cycle review.
31
31
  - **proposed.** An agent proposed a move. It changes nothing until you keep it in the Cycle review.
32
32
  - **Dormant.** An initiative with no activity for a long time. Press the dormant line under Later to show them.
33
+ - **No record yet.** A PRD, rubric or prototype folder that no initiative record points at. Press the "with no record" line under Later to show them. In List they are the last group. Press one to open its PRD. Only a record can be placed, so these have no menu.
33
34
 
34
35
  ## The List
35
36
 
@@ -16,7 +16,8 @@ The top bar has these parts, from left to right:
16
16
  - **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
17
17
  - **Today, Initiatives, Board and Reports** are the four places. Press one to show it. The Today count shows how many items need you.
18
18
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
19
- - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. When the repository is in sync, Sync is not shown.
19
+ - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
20
+ - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
20
21
  - **Settings** opens the settings of the workspace.
21
22
 
22
23
  Each place is drawn when its run is built. Until then the place says which run brings it, for example "Board arrives in Run 4."
@@ -81,7 +82,7 @@ A key that you type in a field, or in a terminal, goes to the field or the termi
81
82
 
82
83
  A phone shows one panel at a time. The tabs at the bottom are **Today, Initiatives, Board, Reports and Agent**. Agent shows the dock, with your terminals. Search or capture ⌘K is pinned at the top of Today.
83
84
 
84
- The bar at the top has Back (when you are deeper than a tab), Repository, the name of the screen, ⌘K, Sync (only when the repository is not in sync) and Settings. Settings opens over the current tab. Back returns to the tab.
85
+ The bar at the top has Back (when you are deeper than a tab), Repository, the name of the screen, ⌘K, Sync (when the repository is not in sync, or has not fetched in the last 10 minutes) and Settings. Settings opens over the current tab. Back returns to the tab.
85
86
 
86
87
  Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
87
88
 
@@ -0,0 +1,28 @@
1
+ ---
2
+ type: Guide
3
+ title: "Report slides: present and print a report"
4
+ description: "How the Reports page shows a report as the PM-OS biweekly slides, how to present and print them, and how your notes become the words on the slides."
5
+ timestamp: 2026-10-06
6
+ ---
7
+
8
+ # Report slides
9
+
10
+ The Reports page shows a report as slides, in the PM-OS biweekly format: the roadmap snapshot, the initiative list, one slide for each initiative, and the release notes. **Present** shows them full screen. Use the arrow keys or swipe to move, and press Escape to close. **Print / PDF** prints one slide on each page.
11
+
12
+ The script writes the data of the slides at the end of the draft, in one `<!-- pmos:slides v1 … -->` block. Do not edit that block. Your words on the slides come from the notes:
13
+
14
+ - The note of an initiative is the note box on its slide. Start it with "Warning:" or "Info:" to set the type of the box. Write each point on its own line that starts with "- ". The box shows four points.
15
+ - The `report.changelog` note is the release notes. Only you write it; the agent leaves it empty. Write one entry for each release. Put a blank line after the title, after the sentence, and between entries:
16
+
17
+ ```
18
+ Launch · Sign-in: Email codes for every title
19
+
20
+ Readers can sign in with a one-time code.
21
+
22
+ - Added: Code entry screen
23
+ - Changed: The login page offers codes first
24
+ ```
25
+
26
+ The type is Launch, Improvement, Fix, Security, Deprecation or Beta. To give an entry its own date, add it after the title, for example `(2026-10-02)`. When the note is empty, the slide says that no release notes are logged.
27
+
28
+ A draft from before the slides has no slides block. Press **Redraft** to add it. Your notes stay as you wrote them.
@@ -43,6 +43,8 @@ Your note. Start it with "Warning:" when the block needs attention.
43
43
 
44
44
  The headline note is `report.headline`. The asks are in the `report.asks` note, one ask on each line, each line starting with "- ". Write prose only between the markers. Do not type numbers: the script writes them.
45
45
 
46
+ The Reports page shows a report as slides. [Report slides](report-slides.md) tells how your notes become the words on the slides.
47
+
46
48
  When you draft a slot again, every note stays as you wrote it, next to its initiative. The order of the blocks can change. A note for an initiative that is no longer in the window moves to "Notes kept from an earlier draft".
47
49
 
48
50
  ## The commands
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ /* auto-sync — Sync without the press, for the PM who forgot to press it (D70 as amended 2026-10-06).
3
+ *
4
+ * The press (POST /api/sync) stays. This module runs the SAME lib/sync.js#sync, with every safety it has (no force,
5
+ * a conflict aborts the rebase whole, records only), at two moments and no others:
6
+ * startup once per repository when the server starts: brings in what another machine pushed.
7
+ * idle on the server's tick: a repository with work not yet pushed, quiet for QUIET_MS, is synced.
8
+ * Before either, a repository must pass every rule below. A rule that cannot be read is a no.
9
+ * - auto-sync is on for this server (see enabledFor);
10
+ * - the branch has an upstream;
11
+ * - in a CODE repository only (profile product-code, D68): the branch is the remote's default branch, and every commit
12
+ * not yet pushed touches record paths only, so a feature branch and code are never auto-pushed. A DOCUMENT
13
+ * repository (profile pm-artifacts) holds only the PM's documents: any branch, every file;
14
+ * - no record file changed in the last QUIET_MS: a document being written is left alone;
15
+ * - no sync is running for the repository, and the last auto-sync did not fail within RETRY_MS.
16
+ */
17
+ const syncLib = require('./sync');
18
+
19
+ const QUIET_MS = 5 * 60 * 1000;
20
+ const RETRY_MS = 30 * 60 * 1000;
21
+
22
+ // On for a real server start. A server given its own state directory (every suite does, through test-run.js) is a
23
+ // scratch server and does not auto-sync unless asked. PMOS_AUTO_SYNC=0 turns it off; =1 turns it on.
24
+ function enabledFor(env = process.env) {
25
+ const v = String(env.PMOS_AUTO_SYNC || '').trim();
26
+ if (v === '0') return false;
27
+ if (v === '1') return true;
28
+ return !String(env.PMOS_STATE_DIR || '').trim();
29
+ }
30
+
31
+ // Why `root` may not auto-sync now, or null when it may. `moment` is 'startup' or 'idle'.
32
+ async function blocker(root, moment, { now = Date.now(), lastFail = 0 } = {}) {
33
+ if (lastFail && now - lastFail < RETRY_MS) return 'the last auto-sync failed; it waits before trying again';
34
+ const st = await syncLib.status(root);
35
+ if (!st.ok) return st.error || 'not a git repository';
36
+ if (!st.upstream) return 'no upstream';
37
+ if (st.state === 'mid-operation') return 'a merge, rebase or cherry-pick is in progress';
38
+ const docs = syncLib.profileOf(root) === 'pm-artifacts';
39
+ const remote = st.upstream.split('/')[0];
40
+ const def = docs ? null : await syncLib.defaultBranchOf(root, remote);
41
+ if (!docs && (!def || st.branch !== def)) return `'${st.branch}' is not the default branch of ${remote}`;
42
+ if (st.ahead && !docs) {
43
+ const paths = await syncLib.unpushedPaths(root, st.upstream);
44
+ if (!paths) return 'the unpushed commits cannot be read';
45
+ const code = paths.filter((p) => !syncLib.isRecord(p));
46
+ if (code.length) return `unpushed commits change files that are not records (${code[0]}${code.length > 1 ? ', …' : ''})`;
47
+ }
48
+ if (moment === 'idle') {
49
+ if (!st.dirty_records && !st.ahead) return 'nothing to send';
50
+ const newest = await syncLib.newestDirtyRecordMs(root);
51
+ if (newest && now - newest < QUIET_MS) return 'a record changed in the last few minutes';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /* createAutoSync({ roots, busy, onDone, env }) → { startup(), tick(), lastOf(root) }
57
+ * roots() the repository roots the server holds
58
+ * busy a Set of roots with a sync running (shared with the press route)
59
+ * onDone(root, result) called after each auto-sync that ran (to refresh caches) */
60
+ function createAutoSync({ roots, busy, onDone = () => {}, env = process.env, label = 'auto-sync' } = {}) {
61
+ const last = new Map(); // root -> { at, moment, ok, message, skipped? }
62
+ async function runFor(root, moment) {
63
+ if (!enabledFor(env) || busy.has(root)) return null;
64
+ const prev = last.get(root);
65
+ const why = await blocker(root, moment, { lastFail: prev && prev.ok === false ? prev.at : 0 });
66
+ if (why) return null;
67
+ busy.add(root);
68
+ try {
69
+ const out = await syncLib.sync(root, { label });
70
+ last.set(root, { at: Date.now(), moment, ok: !!out.ok, message: out.message, stopped_at: out.stopped_at || null });
71
+ try { onDone(root, out); } catch { /* a cache refresh is not the sync's business */ }
72
+ return out;
73
+ } finally { busy.delete(root); }
74
+ }
75
+ async function each(moment) {
76
+ const out = [];
77
+ for (const r of roots()) { try { out.push(await runFor(r, moment)); } catch { /* one repository never stops the others */ } }
78
+ return out;
79
+ }
80
+ return { startup: () => each('startup'), tick: () => each('idle'), lastOf: (root) => last.get(root) || null };
81
+ }
82
+
83
+ module.exports = { createAutoSync, blocker, enabledFor, QUIET_MS, RETRY_MS };