create-pmos 0.36.0 → 0.38.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 (71) hide show
  1. package/bin/create-pmos.js +22 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +6 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/lib/board-cache.js +18 -8
  28. package/workspace/lib/console-state.js +2 -0
  29. package/workspace/lib/engine/anchor-resolve.js +56 -6
  30. package/workspace/lib/engine/pmos-memory.js +375 -0
  31. package/workspace/lib/engine/pmos-model.js +239 -0
  32. package/workspace/lib/engine/pmos-skills.js +274 -0
  33. package/workspace/lib/engine/workflow-state.js +178 -10
  34. package/workspace/lib/focus.js +5 -6
  35. package/workspace/lib/memory.js +54 -0
  36. package/workspace/lib/models.js +50 -0
  37. package/workspace/lib/preview-kind.js +12 -1
  38. package/workspace/lib/schedule-logic.js +31 -2
  39. package/workspace/lib/skill-store.js +5 -1
  40. package/workspace/lib/skills-registry.js +74 -0
  41. package/workspace/lib/sync.js +132 -0
  42. package/workspace/lib/tree-share.js +2 -0
  43. package/workspace/package-lock.json +2 -2
  44. package/workspace/package.json +3 -3
  45. package/workspace/public/pm/app.js +32 -2
  46. package/workspace/public/pm/data.js +160 -10
  47. package/workspace/public/pm/lifecycle.js +105 -0
  48. package/workspace/public/pm/markdown.js +10 -1
  49. package/workspace/public/pm/modes-live.js +371 -0
  50. package/workspace/public/pm/modes.js +7 -4
  51. package/workspace/public/pm/panel-detail.js +22 -2
  52. package/workspace/public/pm/panel-initiatives.js +57 -12
  53. package/workspace/public/pm/panel-newsess.js +25 -0
  54. package/workspace/public/pm/panel-repos.js +130 -4
  55. package/workspace/public/pm/panel-tabs.js +49 -2
  56. package/workspace/public/pm/state.js +11 -1
  57. package/workspace/public/pm/tab-doc.js +160 -26
  58. package/workspace/public/pm/tab-term.js +2 -0
  59. package/workspace/public/pm/tab-web.js +89 -0
  60. package/workspace/public/pm/tokens.css +4 -0
  61. package/workspace/public/pm/xlsx-read.js +117 -0
  62. package/workspace/server.js +690 -40
  63. package/workspace/skills/gather-requirements.skill +53 -0
  64. package/workspace/skills/memory-groom.skill +46 -0
  65. package/workspace/skills/report-biweekly.skill +53 -0
  66. package/workspace/skills/report-daily.skill +53 -0
  67. package/workspace/skills/report-monthly.skill +53 -0
  68. package/workspace/skills/report-special.skill +49 -0
  69. package/workspace/skills/report-weekly.skill +53 -0
  70. package/workspace/skills/skill-discover.skill +49 -0
  71. package/workspace/skills/skill-generate.skill +33 -0
@@ -6,48 +6,34 @@ timestamp: 2026-07-30
6
6
 
7
7
  # PMOS Terminal Workspace
8
8
 
9
- The local companion that runs the PM's agent sessions as **real terminals**, with the PMOS
10
- workflow (library, gates, needs-you) around them. Built to D62 (as ratified + twice amended):
11
- a **persistent, localhost-only session server** owning real PTYs, with a **GUI client** attached
12
- over WebSocket. The native TUI is a deferred later client (D62 amendment 2).
9
+ The local companion that runs the PM's agent sessions as **real terminals**, with the PMOS workflow (library, gates, needs-you) around them. Built to D62 (as ratified + twice amended): a **persistent, localhost-only session server** owning real PTYs, with a **GUI client** attached over WebSocket. The native TUI is a deferred later client (D62 amendment 2).
13
10
 
14
11
  ## Why it can't lose your harness
15
- Each session spawns your agent CLI (Claude Code / Codex / a shell) as a real PTY; xterm.js is a
16
- true terminal emulator on the other end. The CLI runs **untouched** — its own login (Claude
17
- subscription included), slash commands, keybindings. The workspace is the terminal, not a wrapper.
12
+ Each session spawns your agent CLI (Claude Code / Codex / a shell) as a real PTY; xterm.js is a true terminal emulator on the other end. The CLI runs **untouched** — its own login (Claude subscription included), slash commands, keybindings. The workspace is the terminal, not a wrapper.
18
13
 
19
14
  ## Everything survives a session
20
- The server holds PTYs in memory and mirrors each session's scrollback + metadata to the runtime
21
- state directory — **`~/Library/Application Support/pmos` on macOS**, `$XDG_STATE_HOME/pmos` or
22
- `~/.local/state/pmos` elsewhere, `PMOS_STATE_DIR` overriding both. It is deliberately OUTSIDE the
23
- app directory: sessions, the workspace registry, layouts, scratchpads, schedules and your authored
24
- skills used to live at `workspace/state/`, inside the directory the test suite runs from, and were
25
- twice destroyed by a test. An existing in-repo `state/` is moved there once, on first run; two
26
- populated directories are never merged (you get told, and nothing moves).
27
- Close/reload the client → re-attach replays scrollback and the run continues (detach, not kill).
28
- Restart the server → prior sessions reload as `exited` with full history and one-click respawn.
15
+ The server holds PTYs in memory and mirrors each session's scrollback + metadata to the runtime state directory — **`~/Library/Application Support/pmos` on macOS**, `$XDG_STATE_HOME/pmos` or `~/.local/state/pmos` elsewhere, `PMOS_STATE_DIR` overriding both. It is deliberately OUTSIDE the app directory: sessions, the workspace registry, layouts, scratchpads, schedules and your authored skills used to live at `workspace/state/`, inside the directory the test suite runs from, and were twice destroyed by a test. An existing in-repo `state/` is moved there once, on first run; two populated directories are never merged (you get told, and nothing moves). Close/reload the client → re-attach replays scrollback and the run continues (detach, not kill). Restart the server → prior sessions reload as `exited` with full history and one-click respawn.
29
16
 
30
17
  ## Run it — just type `pmos`
31
18
  One-time setup (installs the `pmos` command on your machine):
19
+
32
20
  ```bash
33
21
  cd workspace
34
22
  npm install -g .
35
23
  ```
36
24
  Then, from any terminal, any folder:
25
+
37
26
  ```bash
38
27
  pmos # starts the workspace (installs deps on first run) + opens the browser
39
28
  ```
40
29
  That's it. The server keeps running after you close the terminal — sessions survive (R7).
30
+
41
31
  ```bash
42
32
  pmos status # is it running? which sessions?
43
33
  pmos stop # shut it down (scrollback is preserved; sessions respawn next time)
44
34
  pmos doctor # diagnose the environment (node-pty arch, PTY spawn test, ports)
45
35
  ```
46
- **The port is found, not assumed.** A running server records the port it serves, so `pmos` in a
47
- second repo joins the workspace you already have open — whatever port it ended up on, and however it
48
- was started. If another program holds the default port (7433), `pmos` starts on the next free one and
49
- says so; `PMOS_WS_PORT=7500 pmos` pins it by hand. `pmos stop` shuts down every workspace server it
50
- finds, which is how you collapse two back into one.
36
+ **The port is found, not assumed.** A running server records the port it serves, so `pmos` in a second repo joins the workspace you already have open — whatever port it ended up on, and however it was started. If another program holds the default port (7433), `pmos` starts on the next free one and says so; `PMOS_WS_PORT=7500 pmos` pins it by hand. `pmos stop` shuts down every workspace server it finds, which is how you collapse two back into one.
51
37
 
52
38
  <details><summary>Without the global install (running from the repo)</summary>
53
39
 
@@ -61,209 +47,80 @@ npm run test:ui # browser checks: terminal geometry, reader scroll, markdow
61
47
  </details>
62
48
 
63
49
  ## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
64
- The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top
65
- bar and a 26px status bar, on the oklch token layer, in both themes:
66
-
67
- 1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a
68
- `github|local · N initiatives` sub-line. `+` adds one by picking a folder (typing a path still
69
- works; where no chooser exists, an in-app folder browser stands in). The **settings & tools**
70
- drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` ·
71
- `knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is
72
- deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
73
- 2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
74
- box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
75
- initiatives are filed honestly and shown **rejected** under D59, never hidden.
76
- A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
77
- something to say: every initiative with a live session, a registered working tree on its
78
- `initiative/<id>` branch, or a tab open in this window, ordered so whatever is **waiting on you**
79
- is the first row on the board. The three signals are ranked, never merged, and each row's tooltip
80
- names which one put it there. On a row with a live session the leading `#` becomes that session's
81
- own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the vocabulary
82
- `lib/session-state.js` uses everywhere else, with every session named individually on hover. The
83
- glyphs differ in **shape**, so the signal survives greyscale.
84
- **A session records which initiative it is for**, so `+ session` on an initiative is attributed
85
- to it whatever branch you are on — and that survives a server restart. Where nothing was recorded
86
- the `initiative/<id>` branch still answers, and the row says when the attribution is only a branch
87
- reading. A session PMOS cannot place is never guessed at **and never hidden**: it is listed under
88
- *not on an initiative*, and `+ bind` records what it is for as your own declaration.
89
- 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the
90
- **sessions** running on it (each with the same word the sidebar uses — click one to go to its tab
91
- if it is open, or to open a tab **attached** to the running session; nothing here ever starts an
92
- agent), the derived footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
93
- are derived from the workflow ladder unless the record authors them), a **links** section for
94
- references that are not repo files, and **artifacts** with an external destination and the sync
95
- panel. A record that fails to read renders **red with the parse error** — never a blank panel.
96
- 4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a
97
- real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec).
98
- `+ session`, close, split.
99
-
100
- `⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a
101
- reload.
102
-
103
- - **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no
104
- phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live
105
- terminal host is never re-parented, so a session you navigate away from keeps running.
106
- - **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or
107
- exited, and it survives a server restart.
108
- - **Documents read like documents** — the identity block becomes a card, tables are tables,
109
- nested/ordered/task lists survive, and a relative `.md` link opens that document in the reader.
110
- - **Preview more than markdown** — `.skill` files render as markdown; text/config files open as
111
- escaped source; `.html` opens as source with an opt-in **sandboxed** render (scripts + network
112
- denied); markdown renders **repo-relative images inline**. A remote `![](https://…)` image is
113
- never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
114
-
115
- **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the
116
- server unable to read any repository — that is how the design comparison is run
117
- (`npm run test:parity`), and it is never the default.
50
+ The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top bar and a 26px status bar, on the oklch token layer, in both themes:
51
+
52
+ 1. **Repositories** — every repo you've added, with its kind (`⎇` github / `▤` local) and a `github|local · N initiatives` sub-line. `+` adds one by picking a folder (typing a path still works; where no chooser exists, an in-app folder browser stands in). The **settings & tools** drawer holds the secondary modes in two groups: *this repository* (`agents` · `memory` · `knowledge`) and *all repositories* (`focus` · `skills` · `connections` · `scheduled`). There is deliberately no `workflow` entry — workflows are decided by the agent at runtime, not configured.
53
+ 2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search box that forces the groups open and reports matches elsewhere, and an add form. Unanchored initiatives are filed honestly and shown **rejected** under D59, never hidden. A **`NOW · working on`** group is pinned above the objectives when — and only when — there is something to say: every initiative with a live session, a registered working tree on its `initiative/<id>` branch, or a tab open in this window, ordered so whatever is **waiting on you** is the first row on the board. The three signals are ranked, never merged, and each row's tooltip names which one put it there. On a row with a live session the leading `#` becomes that session's own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the vocabulary `lib/session-state.js` uses everywhere else, with every session named individually on hover. The glyphs differ in **shape**, so the signal survives greyscale. **A session records which initiative it is for**, so `+ session` on an initiative is attributed to it whatever branch you are on — and that survives a server restart. Where nothing was recorded the `initiative/<id>` branch still answers, and the row says when the attribution is only a branch reading. A session PMOS cannot place is never guessed at **and never hidden**: it is listed under *not on an initiative*, and `+ bind` records what it is for as your own declaration.
54
+ 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the **sessions** running on it (each with the same word the sidebar uses — click one to go to its tab if it is open, or to open a tab **attached** to the running session; nothing here ever starts an agent), the derived footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks are derived from the workflow ladder unless the record authors them), a **links** section for references that are not repo files, and **artifacts** with an external destination and the sync panel. A record that fails to read renders **red with the parse error** — never a blank panel.
55
+ 4. **Tabs** — scoped per repository, two-line labels naming the initiative, three kinds: **term** (a real PTY), **doc** (view/raw/edit/diff, save/revert, dirty flag), and **task** (the task spec). `+ session`, close, split.
56
+
57
+ `⌘K`/`Ctrl-K` focuses search; `Esc` closes the active tab. The panel state you choose survives a reload.
58
+
59
+ - **A phone surface.** The same four panel modules render on a phone — one shell, one renderer, no phone-shaped copy of a list. `lib/mobile-nav.js` owns the destinations and the stack; the live terminal host is never re-parented, so a session you navigate away from keeps running.
60
+ - **Rename a session any time** — the ✎ on its row, a double-click on its tab, or `F2`. Live or exited, and it survives a server restart.
61
+ - **Documents read like documents** — the identity block becomes a card, tables are tables, nested/ordered/task lists survive, and a relative `.md` link opens that document in the reader.
62
+ - **Preview more than markdown** — `.skill` files render as markdown; text/config files open as escaped source; `.html` opens as source with an opt-in **sandboxed** render (scripts + network denied); markdown renders **repo-relative images inline**. A remote `![](https://…)` image is never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
63
+
64
+ **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the server unable to read any repository — that is how the design comparison is run (`npm run test:parity`), and it is never the default.
118
65
 
119
66
  ## Artifact links are declarations, not integrations
120
- PMOS makes no outbound request and holds no credential. Linking an artifact to an external
121
- destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to
122
- `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector
123
- configured its summary line says exactly that: *no connector configured — nothing was read, and
124
- nothing will be written*, with the approval buttons disabled. A difference summary it never read
125
- would be the watermelon this repo has a word for.
67
+ The PMOS server makes no outbound request and holds no credential, and the workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
126
68
 
127
69
  ## The gates are real, never automatic (R5)
128
- `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and
129
- clear the matching needs entry — only ever on your click.
70
+ `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and clear the matching needs entry — only ever on your click.
130
71
 
131
- **The record lands in the repo that earned it.** Each workspace resolves its own state dir —
132
- `pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; an embedded PMOS-self checkout
133
- with none of those yet uses the install's own `workspace/state/`. A PMOS-shaped repo with no state
134
- dir yet gets the kit layout on first write. That path matters: `scripts/metrics.py` reads
135
- `pmos/state/acceptances.jsonl` for the north star and κ, so an acceptance recorded anywhere else is
136
- a green click over an empty record. A plain (non-PMOS) repo has no layout to invent — the gate
137
- records into `workspace/state/` and the response says `fallback: true` rather than guessing.
72
+ **The record lands in the repo that earned it.** Each workspace resolves its own state dir — `pmos/state/` (the kit's layout, D54), then `state/`, then `.pmos/`; an embedded PMOS-self checkout with none of those yet uses the install's own `workspace/state/`. A PMOS-shaped repo with no state dir yet gets the kit layout on first write. That path matters: `scripts/metrics.py` reads `pmos/state/acceptances.jsonl` for the north star and κ, so an acceptance recorded anywhere else is a green click over an empty record. A plain (non-PMOS) repo has no layout to invent — the gate records into `workspace/state/` and the response says `fallback: true` rather than guessing.
138
73
 
139
- This is D64's **system of record** and it did **not** follow the runtime state out to the per-user
140
- directory above. A record belongs to the repository that earned it; only the churny, machine-written
141
- runtime moved.
74
+ This is D64's **system of record** and it did **not** follow the runtime state out to the per-user directory above. A record belongs to the repository that earned it; only the churny, machine-written runtime moved.
142
75
 
143
- Records carry the kit's `accepted` field (`accept` → true, `return` → false) so file-mode metrics
144
- count them. `pm_label` is **never** derived from a click — κ labels are the PM's own word (D40).
76
+ Records carry the kit's `accepted` field (`accept` → true, `return` → false) so file-mode metrics count them. `pm_label` is **never** derived from a click — κ labels are the PM's own word (D40).
145
77
 
146
78
  ## Your own runs are searchable (the history lens)
147
- Every session's scrollback is already mirrored to disk, which makes it a record of what earlier
148
- runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…`
149
- searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild
150
- has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)
151
-
152
- Two guarantees. It is scoped to **one repo** — a workspace never searches another's history
153
- (R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less.
154
- And every returned line is **scrubbed** of ANSI and of anything credential-shaped (bearer
155
- tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the agent printed,
156
- so D08's never-log-headers discipline applies to what this hands back. Matching runs against
157
- the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
79
+ Every session's scrollback is already mirrored to disk, which makes it a record of what earlier runs actually **did** — the thing structured run rows don't carry. `GET /api/search?q=…&ws=…` searches it, and an agent working in a pane can ask the question itself. (The four-panel rebuild has not yet re-surfaced this as a UI lens — the endpoint is live, the front-end entry point is not.)
80
+
81
+ Two guarantees. It is scoped to **one repo** — a workspace never searches another's history (R1/R3), and an unrecognised workspace id resolves to home, so it can only ever see less. And every returned line is **scrubbed** of ANSI and of anything credential-shaped (bearer tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the agent printed, so D08's never-log-headers discipline applies to what this hands back. Matching runs against the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
158
82
 
159
83
  ## `search everything…` searches the folder (the file lens)
160
- The search box shipped matching three derived lists — initiative slugs, paths **declared on** an
161
- initiative record, and session names — so a file PMOS had never been told about could not be found
162
- by typing its exact name, and the miss printed *"nothing matches — a successful read of nothing"*
163
- after a search that had never opened the folder.
164
-
165
- `GET /api/search/files?q=…&ws=…&limit=…` is the folder corpus: every file under the workspace root,
166
- matched by **path** and by **content**, with the matching line and its number. Clicking a hit opens
167
- it in the ordinary document tab. It is a **different corpus** from `/api/search` above — that one is
168
- scrollback, this one is disk — and they are deliberately separate endpoints, because their hits
169
- share no field and one payload would have to lie about what it looked at.
170
-
171
- Three guarantees. The walk uses the **folder lens's own rules** (`node_modules`, `dist`, `.git` and
172
- the rest skipped, dotted names excluded), so search and the file tree can never disagree about what
173
- is in the folder. There is **no path parameter** — the query is a needle, not a location — and the
174
- walk starts at the root and never follows a symlink out of it, so nothing becomes reachable that
175
- `/api/doc` would refuse; `openable` on each hit reports whether the reader can actually show it.
176
- And it is **bounded, out loud**: `truncated` means files were never looked at, `content_truncated`
177
- means every file was named but not every body was read, `scanned`/`content_scanned` give the counts.
178
- Two caps, two flags — merged into one, the serious flag would light on any repository with a lock
179
- file in it, which is the same as retiring it.
84
+ The search box shipped matching three derived lists — initiative slugs, paths **declared on** an initiative record, and session names — so a file PMOS had never been told about could not be found by typing its exact name, and the miss printed *"nothing matches — a successful read of nothing"* after a search that had never opened the folder.
85
+
86
+ `GET /api/search/files?q=…&ws=…&limit=…` is the folder corpus: every file under the workspace root, matched by **path** and by **content**, with the matching line and its number. Clicking a hit opens it in the ordinary document tab. It is a **different corpus** from `/api/search` above — that one is scrollback, this one is disk — and they are deliberately separate endpoints, because their hits share no field and one payload would have to lie about what it looked at.
87
+
88
+ Three guarantees. The walk uses the **folder lens's own rules** (`node_modules`, `dist`, `.git` and the rest skipped, dotted names excluded), so search and the file tree can never disagree about what is in the folder. There is **no path parameter** — the query is a needle, not a location — and the walk starts at the root and never follows a symlink out of it, so nothing becomes reachable that `/api/doc` would refuse; `openable` on each hit reports whether the reader can actually show it. And it is **bounded, out loud**: `truncated` means files were never looked at, `content_truncated` means every file was named but not every body was read, `scanned`/`content_scanned` give the counts. Two caps, two flags — merged into one, the serious flag would light on any repository with a lock file in it, which is the same as retiring it.
180
89
 
181
90
  ## Branches: a new one doesn't move the others
182
91
 
183
- Starting a session, the branch control is **optional** — the default is "stay on current" and it
184
- performs no git operation at all. When you do pick one:
185
-
186
- - **`+ new branch…`** can give that branch its **own working tree**, at
187
- `../<repo>-worktrees/<branch>` (a sibling of the repo — nothing appears inside it, nothing for
188
- `.gitignore` to chase), registered as its own workspace. Every session already running stays
189
- exactly where it was. The folder is named in the form before you commit to it, and again in the
190
- footer once it exists.
191
- - **That's the default only when there are sessions to protect.** Alone in a workspace a plain
192
- checkout disturbs nobody, so no folder is made. The checkbox is yours to flip either way.
193
- - **Unchecking "give it its own folder"** is the shared-tree checkout: this repo moves, and so does
194
- every session in it. The hint counts them, and only warns when something really would move.
195
- - A branch can live in **one** working tree at a time. One already open elsewhere is labelled with
196
- its location in the picker, rather than failing later.
197
-
198
- **⑂ worktrees** in the sidebar lists every working tree the repo has, and removes the ones that are
199
- safe to remove. A removal is refused — with the reason — when the tree has sessions running in it,
200
- uncommitted changes, or commits that no other branch, remote or tag holds. Removing one reclaims
201
- the folder and **keeps the branch**; nothing is ever removed automatically. Git history is shared
202
- between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
203
- repo.
92
+ Starting a session, the branch control is **optional** — the default is "stay on current" and it performs no git operation at all. When you do pick one:
93
+
94
+ - **`+ new branch…`** can give that branch its **own working tree**, at `../<repo>-worktrees/<branch>` (a sibling of the repo — nothing appears inside it, nothing for `.gitignore` to chase), registered as its own workspace. Every session already running stays exactly where it was. The folder is named in the form before you commit to it, and again in the footer once it exists.
95
+ - **That's the default only when there are sessions to protect.** Alone in a workspace a plain checkout disturbs nobody, so no folder is made. The checkbox is yours to flip either way.
96
+ - **Unchecking "give it its own folder"** is the shared-tree checkout: this repo moves, and so does every session in it. The hint counts them, and only warns when something really would move.
97
+ - A branch can live in **one** working tree at a time. One already open elsewhere is labelled with its location in the picker, rather than failing later.
98
+
99
+ **⑂ worktrees** in the sidebar lists every working tree the repo has, and removes the ones that are safe to remove. A removal is refused — with the reason — when the tree has sessions running in it, uncommitted changes, or commits that no other branch, remote or tag holds. Removing one reclaims the folder and **keeps the branch**; nothing is ever removed automatically. Git history is shared between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the repo.
204
100
 
205
101
  ## Several sessions in one tree: you are told, never stopped
206
102
 
207
- Sessions in one working tree share **one branch, one index and one set of files**. Starting a
208
- session in a tree that already has live ones now says so in the pane — naming them, and naming the
209
- way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
210
- git operation runs at all: the server already knew, and used to say nothing.
211
-
212
- **`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
213
- act on, and the answer you usually want is not "start another" at all — it is "which of those is the
214
- one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
215
- first, listing every session in that tree with:
216
-
217
- - **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
218
- `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
219
- - **what ending it would cost** — one sentence per session, worded from the evidence. Only
220
- `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
221
- `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
222
- discards a running turn is worse than no reading at all;
103
+ Sessions in one working tree share **one branch, one index and one set of files**. Starting a session in a tree that already has live ones now says so in the pane — naming them, and naming the way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no git operation runs at all: the server already knew, and used to say nothing.
104
+
105
+ **`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot act on, and the answer you usually want is not "start another" at all — it is "which of those is the one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser first, listing every session in that tree with:
106
+
107
+ - **what it is** — its name, its agent, the sidebar's own word for its state (`working` · `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
108
+ - **what ending it would cost** — one sentence per session, worded from the evidence. Only `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that discards a running turn is worse than no reading at all;
223
109
  - **`open it`** — attach a tab to that session and start nothing.
224
110
 
225
- `end it` is there too, and is the only destructive control on the surface: two clicks, the first
226
- arming it. `+ start another session here anyway` is always one click away — informing you is never
227
- obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
228
- back tomorrow answers the same question the chooser did.
229
-
230
- **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
231
- `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
232
- its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
233
- warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
234
- and says nothing, because there is no index to collide over.
235
-
236
- **The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
237
- others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
238
- `POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
239
- timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
240
- promoted, because which pane should be driving is your intention and not something to derive.
241
-
242
- Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
243
- the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
244
- because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
245
- (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
246
- not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
247
- chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
248
- each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
249
-
250
- A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
251
- counts, which matters for a product repo added by path. A folder with **no git** simply says so and
252
- starts the session; nothing about git can block work in a repo that doesn't use it.
111
+ `end it` is there too, and is the only destructive control on the surface: two clicks, the first arming it. `+ start another session here anyway` is always one click away — informing you is never obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling back tomorrow answers the same question the chooser did.
112
+
113
+ **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not warned about at all: those are the sessions isolation made safe. A folder with no git has no tree and says nothing, because there is no index to collide over.
114
+
115
+ **The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it; `POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is promoted, because which pane should be driving is your intention and not something to derive.
116
+
117
+ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused because someone else is in the tree. It makes a shared tree visible; the discipline is still yours. (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
118
+
119
+ A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`) counts, which matters for a product repo added by path. A folder with **no git** simply says so and starts the session; nothing about git can block work in a repo that doesn't use it.
253
120
 
254
121
  ## Boundaries (honest v1 seams)
255
- - **Server binds 127.0.0.1** — the D62 hosted-PTY-bridge rejection stands; a tailnet/phone client
256
- attaches to the same local server, never a public endpoint.
257
- - **needs-you feed** — an explicit `needs.json` (written by the harness/MCP) is authoritative for
258
- the entries it names; everything else is **derived** from the repo's own append-only state, so a
259
- repo nobody has seeded still shows its real work. Derived: an eval with no acceptance for its run
260
- (the Acceptance Gate is open, D12), and a declared outcome past its horizon with no disposition
261
- (the D57 check is due — a *missing* horizon counts as due, mirroring `metrics.py`). Derivation
262
- only reports: an overdue outcome carries **no** close button, because disposal is
263
- `record_outcome_check` with evidence, and auto-disposal is prohibited (D57).
264
- - File reads are **path-guarded** to the repo. The reader serves previewable **text kinds**
265
- (markdown incl. `.skill`, text/config, HTML source) and streams repo-relative **images** through
266
- a MIME-allowlisted `/api/asset`; binaries and out-of-root/cross-workspace paths are refused.
267
- Remote images are never fetched (no beacon). Request headers are never logged (D08).
122
+ - **Server binds 127.0.0.1** — the D62 hosted-PTY-bridge rejection stands; a tailnet/phone client attaches to the same local server, never a public endpoint.
123
+ - **needs-you feed** — an explicit `needs.json` (written by the harness/MCP) is authoritative for the entries it names; everything else is **derived** from the repo's own append-only state, so a repo nobody has seeded still shows its real work. Derived: an eval with no acceptance for its run (the Acceptance Gate is open, D12), and a declared outcome past its horizon with no disposition (the D57 check is due — a *missing* horizon counts as due, mirroring `metrics.py`). Derivation only reports: an overdue outcome carries **no** close button, because disposal is `record_outcome_check` with evidence, and auto-disposal is prohibited (D57).
124
+ - File reads are **path-guarded** to the repo. The reader serves previewable **text kinds** (markdown incl. `.skill`, text/config, HTML source) and streams repo-relative **images** through a MIME-allowlisted `/api/asset`; binaries and out-of-root/cross-workspace paths are refused. Remote images are never fetched (no beacon). Request headers are never logged (D08).
268
125
 
269
126
  Screenshots: `docs/screenshot-dark.png`, `docs/screenshot-light.png`, `docs/screenshot-doc.png`.
@@ -40,15 +40,25 @@ function headSha(root) {
40
40
  // The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
41
41
  // per record, no git, no file contents.
42
42
  function recordsSig(root) {
43
- const dir = path.join(root, 'planning', 'initiatives');
44
- let names = [];
45
- try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { return 'none'; }
46
- let newest = 0;
47
- for (const n of names) {
48
- try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
49
- catch { /* a record that vanished mid-scan just does not raise the watermark */ }
43
+ // Both layouts (pmos-self `planning/`, a PM repository's `pmos/state/`), and the ledgers the
44
+ // board's rows depend on: an uncommitted lifecycle or acceptance line must not wait out the TTL (D70).
45
+ const dirs = [path.join(root, 'planning', 'initiatives'), path.join(root, 'pmos', 'state', 'initiatives')];
46
+ const ledgers = ['lifecycle.jsonl', 'acceptances.jsonl'].flatMap((f) => [
47
+ path.join(root, 'planning', 'runs', f), path.join(root, 'pmos', 'state', f)]);
48
+ let total = 0; let newest = 0; let any = false;
49
+ for (const dir of dirs) {
50
+ let names = [];
51
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { continue; }
52
+ any = true; total += names.length;
53
+ for (const n of names) {
54
+ try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
55
+ catch { /* a record that vanished mid-scan just does not raise the watermark */ }
56
+ }
50
57
  }
51
- return `${names.length}:${Math.round(newest)}`;
58
+ if (!any) return 'none';
59
+ let led = 0;
60
+ for (const f of ledgers) { try { const st = fs.statSync(f); led = Math.max(led, Math.round(st.mtimeMs)) + st.size; } catch { /* absent */ } }
61
+ return `${total}:${Math.round(newest)}:${led}`;
52
62
  }
53
63
 
54
64
  function signature(root) { return `${headSha(root)}|${recordsSig(root)}`; }
@@ -226,6 +226,8 @@ function workflow({ root }) {
226
226
  hidden, // reported on the client — a silent cap reads as "that is all"
227
227
  total,
228
228
  accepted: rows.filter((r) => r.ok && r.accepted).length,
229
+ // D70 — stopped initiatives are off the board and not gating, but listed, never dropped silently.
230
+ stopped: rows.filter((r) => r.ok && r.stopped).map((r) => ({ id: r.id, title: r.title, state: r.stopped.state, reason: r.stopped.reason, at: r.stopped.at, pm: r.stopped.pm })),
229
231
  unreadable: rows.filter((r) => !r.ok).map(shape),
230
232
  blocked: shown.filter((r) => r.ok && r.blockers.length).length,
231
233
  degraded: range.ok ? null : range.reason,
@@ -82,6 +82,7 @@ const RE_KR_BULLET = new RegExp('^\\s*[-*]\\s*\\*\\*\\s*' + KR_TOKEN + '\\s*[—
82
82
 
83
83
  // Anchor forms. Order matters only in that the colon forms are tried before the looser ones.
84
84
  const RE_HEALTH = /^health:([a-z0-9_-]+)/i; // START-anchored — rule 2
85
+ const RE_DOMAIN = /^domain:([a-z0-9][a-z0-9_-]*)/i; // D67 — the third anchor class
85
86
  const RE_COLON_KR = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*):(kr-?[a-z0-9-]+)$', 'i');
86
87
  const RE_COLON_OBJ = new RegExp('^(?:okrs-)?' + PERIOD + ':([a-z0-9][a-z0-9-]*)$', 'i');
87
88
  const RE_ARROW = new RegExp('^(?:okrs-)?' + PERIOD + '\\s*(?:→|->)\\s*' + OBJ_TOKEN + '(?:\\.' + KR_TOKEN + ')?$', 'i');
@@ -176,7 +177,11 @@ function parseOkrIndex(root) {
176
177
  // A KR with no open objective stays UNATTACHED (rule 3) — never inherited from a previous one.
177
178
  if (k && cur && !cur.krs.some((x) => x.id === krId(k[1]))) {
178
179
  cur.krs.push({ id: krId(k[1]), token: k[1], name: (k[2] || '').trim() });
180
+ continue;
179
181
  }
182
+ // the KR's own measure — its `baseline: … → target: …` sub-bullet (overhaul-r2 B4)
183
+ const bl = /^\s+[-*]\s*baseline\s*:\s*(.+)$/i.exec(line);
184
+ if (bl && cur && cur.krs.length && !cur.krs[cur.krs.length - 1].line) cur.krs[cur.krs.length - 1].line = bl[1].trim();
180
185
  }
181
186
  }
182
187
  return { ok: true, objectives, files: files.map((f) => path.relative(root, f)), period: period || null, error: null };
@@ -184,8 +189,36 @@ function parseOkrIndex(root) {
184
189
 
185
190
  function readBudgets(root) {
186
191
  const p = path.join(root, 'planning', 'okrs', 'health-budgets.md');
187
- try { return { ok: true, text: fs.readFileSync(p, 'utf8'), path: path.relative(root, p) }; }
188
- catch { return { ok: false, text: '', path: path.relative(root, p) }; }
192
+ let out;
193
+ try { out = { ok: true, text: fs.readFileSync(p, 'utf8'), path: path.relative(root, p) }; }
194
+ catch { out = { ok: false, text: '', path: path.relative(root, p) }; }
195
+ return withDomains(out, path.dirname(p), root);
196
+ }
197
+
198
+ /**
199
+ * D67 — the domain registry, `domains.md` beside `health-budgets.md`. Closed and PM-edited, like the
200
+ * budgets: a domain exists only as a table row `| domain:<id> | standing goal | repositories |`.
201
+ * It rides on the budgets object every caller already passes, so the gate, the ladder, the sweep and
202
+ * the workspace all learn the class without a signature change. Absent file = no domains declared
203
+ * (`ok:false`), which makes any `domain:` anchor `unknown`, never silently resolved.
204
+ */
205
+ function readDomains(dir, root) {
206
+ const p = path.join(dir, 'domains.md');
207
+ const rel = root ? path.relative(root, p) : p;
208
+ let text;
209
+ try { text = fs.readFileSync(p, 'utf8'); } catch { return { ok: false, path: rel, rows: [] }; }
210
+ const rows = [];
211
+ for (const line of text.split('\n')) {
212
+ const m = /^\|\s*`?domain:([a-z0-9][a-z0-9_-]*)`?\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|/i.exec(line);
213
+ if (m) rows.push({ id: m[1].toLowerCase(), goal: m[2], repos: m[3] });
214
+ }
215
+ return { ok: true, path: rel, rows };
216
+ }
217
+
218
+ /** Attach the domain registry to a budgets object (callers that build their own budgets use this). */
219
+ function withDomains(budgets, dir, root) {
220
+ if (!budgets) return budgets;
221
+ return Object.assign(budgets, { domains: readDomains(dir, root) });
189
222
  }
190
223
 
191
224
  /**
@@ -221,9 +254,24 @@ function unquote(s) {
221
254
 
222
255
  function resolveAnchor(raw, index, budgets) {
223
256
  const original = unquote(String(raw == null ? '' : raw).trim());
224
- const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
257
+ const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null,
258
+ // separate fields so no client has to parse `reason` (overhaul-r2 B1)
259
+ objective_name: null, kr_name: null, kr_line: null, domain: null, domain_goal: null };
225
260
  if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
226
261
 
262
+ const d = RE_DOMAIN.exec(original);
263
+ if (d) {
264
+ const id = d[1].toLowerCase();
265
+ const out = Object.assign({}, base, { kind: 'domain', domain: id, form: 'domain:<id>' });
266
+ const reg = budgets && budgets.domains;
267
+ if (!reg) return Object.assign(out, { status: 'unknown', reason: 'domain registry not supplied to the resolver' });
268
+ if (!reg.ok) return Object.assign(out, { status: 'unknown', reason: `no domain registry at ${reg.path} — declare the domain there (D67)` });
269
+ const row = reg.rows.find((r) => r.id === id);
270
+ return row
271
+ ? Object.assign(out, { status: 'resolved', domain_goal: row.goal || null, reason: `domain:${id} — ${row.goal || 'no standing goal written'}`.slice(0, 160) })
272
+ : Object.assign(out, { status: 'unresolvable', reason: `domain:${id} is not a declared domain in ${reg.path} (D67)` });
273
+ }
274
+
227
275
  // Rule 2: the FORM decides, not a substring. Commentary is stripped only for the OKR branch,
228
276
  // because a health anchor's id must sit at the very start of the raw value.
229
277
  const h = RE_HEALTH.exec(original);
@@ -269,13 +317,14 @@ function resolveAnchor(raw, index, budgets) {
269
317
  }
270
318
  if (!out.kr) {
271
319
  // Assumption A2: objective-only anchoring is a legitimate resolvable kind, not debt.
272
- return Object.assign(out, { status: 'resolved', reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
320
+ return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, reason: `objective '${obj.id}' — no KR written (objective-level anchor)` });
273
321
  }
274
322
  const kr = obj.krs.find((k) => k.id === out.kr);
275
323
  if (!kr) {
276
324
  return Object.assign(out, { status: 'unresolvable', reason: `objective '${obj.id}' declares no KR '${out.kr}' (has: ${obj.krs.map((k) => k.id).join(', ') || 'none'})` });
277
325
  }
278
- return Object.assign(out, { status: 'resolved', reason: `${obj.id}/${kr.id} — ${kr.name}`.slice(0, 160) });
326
+ return Object.assign(out, { status: 'resolved', objective_name: obj.name || null, kr_name: kr.name || null, kr_line: kr.line || null,
327
+ reason: `${obj.id}/${kr.id} — ${kr.name}`.slice(0, 160) });
279
328
  }
280
329
 
281
330
  /**
@@ -285,6 +334,7 @@ function resolveAnchor(raw, index, budgets) {
285
334
  function canonical(resolved, fallbackPeriod) {
286
335
  if (!resolved) return null;
287
336
  if (resolved.kind === 'health') return resolved.budget ? `health:${resolved.budget}` : null;
337
+ if (resolved.kind === 'domain') return resolved.domain ? `domain:${resolved.domain}` : null;
288
338
  if (resolved.kind !== 'okr' || !resolved.objective) return null;
289
339
  const period = resolved.period || fallbackPeriod || null;
290
340
  if (!period) return null;
@@ -293,7 +343,7 @@ function canonical(resolved, fallbackPeriod) {
293
343
 
294
344
  module.exports = {
295
345
  unquote,
296
- parseOkrIndex, readBudgets, resolveAnchor, canonical,
346
+ parseOkrIndex, readBudgets, readDomains, withDomains, resolveAnchor, canonical,
297
347
  objectiveId, krId, stripCommentary, listOkrFiles,
298
348
  ACCEPTED_FORMS,
299
349
  // exported for the tests that assert the widening happened together (R-4)