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.
- package/bin/create-pmos.js +22 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +6 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +371 -0
- package/workspace/public/pm/modes.js +7 -4
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +690 -40
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
package/workspace/README.md
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 `` 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 `` 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
|
-
|
|
149
|
-
searches
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
257
|
-
- **
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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',
|
|
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)
|