create-pmos 0.164.0 → 0.166.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +1 -0
- package/workspace/guide/settings.md +1 -1
- package/workspace/guide/validate-with-typesafe.md +14 -1
- package/workspace/lib/typesafe-settings.js +48 -15
- package/workspace/lib/typesafe.js +73 -12
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-notes.js +19 -1
- package/workspace/public/pm/c3-settings.js +29 -1
- package/workspace/replaced-tests.json +11 -0
- package/workspace/server.js +17 -0
- package/workspace/test-files.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.166.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T13:44:56Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T13:44:56Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T13:44:56Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -284,6 +284,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
|
|
|
284
284
|
- In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
285
285
|
- **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
|
|
286
286
|
- **Delete note** in the **⋯** menu, or **Delete** on a row of **All notes**, removes the note, its transcript, its summaries and its audio from this machine after you confirm. The note's summary goes from the calendar and the initiative. You cannot undo it. Deleting a note that is not being taken does not stop the one that is.
|
|
287
|
+
- On the console3 page, open the note in **Calendar › Notes** and press the bin next to **Copy as Markdown** in its head. PMOS asks first: **Delete note** deletes it, **Keep it** keeps it. A note that is being recorded stops without keeping its audio, then it is deleted. The next note in the list opens.
|
|
287
288
|
|
|
288
289
|
## What stays on your machine
|
|
289
290
|
|
|
@@ -33,7 +33,7 @@ Settings is not a tab. While it is open, no tab is selected. Press a tab to go b
|
|
|
33
33
|
| Sharing | How a dashboard is shared: off, a link, or sent by the sender. The audiences of the workspace. |
|
|
34
34
|
| Access and devices | Use from a phone or another computer: the host, the tunnel and the people who may open it. |
|
|
35
35
|
| Claude sessions | What a new session starts with: the CLI, the model, the Claude profile and the launch flags. The session limits. Validate consent for this workspace. |
|
|
36
|
-
| Privacy and data | A list of what leaves this computer and when. Whether a TypeSafe key is saved, and the data folder. |
|
|
36
|
+
| Privacy and data | A list of what leaves this computer and when. Whether a TypeSafe key is saved, the local model that answers when there is no key (Off, Winnow or Laya), and the data folder. |
|
|
37
37
|
| Appearance | The theme (Same as system, Light or Dark) and the page that opens first. |
|
|
38
38
|
| Updates | The version of the app and of each workspace's kit. Check for updates, and how updates are found and applied. |
|
|
39
39
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: validate a repository"
|
|
4
4
|
description: "How the validate item checks that a repository agrees with itself, and the optional second check with TypeSafe."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: validate a repository
|
|
@@ -107,6 +107,19 @@ The workspace reads the TypeSafe key only on this machine. After you save the ke
|
|
|
107
107
|
3. Under **this repository**, press **turn on**. This choice applies to the selected repository only.
|
|
108
108
|
4. Enter the paths that the workspace must never send, one for each line. A path ends with `/` for a folder. A `*` matches any characters. Press **save excluded paths**.
|
|
109
109
|
|
|
110
|
+
## Use a model on this computer when there is no TypeSafe key
|
|
111
|
+
|
|
112
|
+
If you have no TypeSafe key, a model on your computer can answer the same questions. Two models are supported. Both use the same interface as Jev.
|
|
113
|
+
|
|
114
|
+
- **Winnow**: in a `winnow-inference` folder, run `python3 scripts/serve.py`. It uses port 8091. Winnow-12B needs about 16 GB of memory.
|
|
115
|
+
- **Laya**: run `pip install "laya[serve]"`, then `laya-serve`. It uses port 8000. Laya is small and runs on a laptop.
|
|
116
|
+
|
|
117
|
+
1. Start the model.
|
|
118
|
+
2. Open Settings, then **Privacy and data**. Under **Local model if Jev is missing**, press **Winnow** or **Laya**.
|
|
119
|
+
3. Press **Test**. The row says whether the model answered.
|
|
120
|
+
|
|
121
|
+
If no key is saved, every Jev question goes to the local model. If a key is saved, TypeSafe answers first. If TypeSafe cannot be reached, rejects the key or is busy, the local model answers. The workspace sends these questions only to `127.0.0.1`, so nothing leaves your computer. It sends no key. The answers of a local model are suggestions, as the answers of Jev are. Press **Off** to stop using the local model.
|
|
122
|
+
|
|
110
123
|
## Run a validation
|
|
111
124
|
|
|
112
125
|
1. Press **show what would be sent**. The workspace shows the number of requests, the number of characters, and the documents it withholds. It sends nothing at this step.
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* returns the key. `getKey()` is for the server's validation path only; no route returns its value.
|
|
10
10
|
* `TYPESAFE_API_KEY` in the environment is read as a fallback, ONCE, at server start (`adoptEnv`), which
|
|
11
11
|
* then deletes the variable from the process environment. Nothing the server spawns afterwards can see it.
|
|
12
|
+
*
|
|
13
|
+
* LOCAL FALLBACK (D95). The same file keeps `local: { backend, port }`: the System One server the PM runs on this
|
|
14
|
+
* machine (Winnow or Laya). Every read hands it to lib/typesafe.js (`configureLocal`). With no key saved, `getKey`
|
|
15
|
+
* returns `typesafe.LOCAL_KEY` so the bridges' key gate opens and their questions go to loopback; `status` says
|
|
16
|
+
* `source: 'local'`. With a key saved, the local server is only asked when TypeSafe fails.
|
|
12
17
|
*/
|
|
13
18
|
const fs = require('fs');
|
|
14
19
|
const path = require('path');
|
|
15
|
-
const
|
|
20
|
+
const typesafe = require('./typesafe');
|
|
21
|
+
const { validKey } = typesafe;
|
|
16
22
|
|
|
17
23
|
const FILE = 'typesafe.json';
|
|
18
24
|
const ENV_NAME = 'TYPESAFE_API_KEY';
|
|
@@ -23,13 +29,20 @@ function file(stateDir) { return path.join(stateDir, FILE); }
|
|
|
23
29
|
function read(stateDir) {
|
|
24
30
|
try {
|
|
25
31
|
const st = fs.lstatSync(file(stateDir));
|
|
26
|
-
if (!st.isFile()) return { key: null, repos: {} };
|
|
32
|
+
if (!st.isFile()) return { key: null, repos: {}, local: localFrom(null) };
|
|
27
33
|
const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8'));
|
|
28
34
|
return {
|
|
29
35
|
key: typeof o.key === 'string' ? o.key : null,
|
|
30
36
|
repos: o && typeof o.repos === 'object' && o.repos && !Array.isArray(o.repos) ? o.repos : {},
|
|
37
|
+
local: localFrom(o && o.local),
|
|
31
38
|
};
|
|
32
|
-
} catch { return { key: null, repos: {} }; }
|
|
39
|
+
} catch { return { key: null, repos: {}, local: localFrom(null) }; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* The saved local server, checked, and handed to the TypeSafe module so the process follows the file. */
|
|
43
|
+
function localFrom(l) {
|
|
44
|
+
const t = typesafe.configureLocal(l && typeof l === 'object' ? { backend: l.backend, port: l.port } : null);
|
|
45
|
+
return t ? { backend: t.backend, port: t.port } : null;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
function write(stateDir, data) {
|
|
@@ -59,30 +72,50 @@ function adoptEnv(env) {
|
|
|
59
72
|
}
|
|
60
73
|
function envKey(env) { return adopted || (env && validKey(env[ENV_NAME]) ? env[ENV_NAME] : null); }
|
|
61
74
|
|
|
62
|
-
/** The key for the server's own use, or null. File first, then the environment. */
|
|
75
|
+
/** The key for the server's own use, or null. File first, then the environment, then the local server's handle. */
|
|
63
76
|
function getKey(stateDir, env = null) {
|
|
64
|
-
const
|
|
65
|
-
if (validKey(
|
|
66
|
-
return envKey(env);
|
|
77
|
+
const cur = read(stateDir);
|
|
78
|
+
if (validKey(cur.key)) return cur.key;
|
|
79
|
+
return envKey(env) || (cur.local ? typesafe.LOCAL_KEY : null);
|
|
67
80
|
}
|
|
68
81
|
|
|
69
|
-
/** What the browser may know: present or not, and from where. Never the value, never its length.
|
|
82
|
+
/** What the browser may know: present or not, and from where. Never the value, never its length.
|
|
83
|
+
* `local` is the fallback server (backend and port; neither is a secret). */
|
|
70
84
|
function status(stateDir, env = null) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { present:
|
|
85
|
+
const cur = read(stateDir);
|
|
86
|
+
const local = cur.local ? { backend: cur.local.backend, name: typesafe.BACKENDS[cur.local.backend].name, port: cur.local.port } : null;
|
|
87
|
+
if (validKey(cur.key)) return { present: true, source: 'settings', local };
|
|
88
|
+
if (envKey(env)) return { present: true, source: 'environment', local };
|
|
89
|
+
if (local) return { present: true, source: 'local', local };
|
|
90
|
+
return { present: false, source: null, local: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Save or clear the local fallback server. `backend` is 'winnow' or 'laya'; `port` defaults to that server's own. */
|
|
94
|
+
function setLocal(stateDir, { backend, port } = {}) {
|
|
95
|
+
const cur = read(stateDir);
|
|
96
|
+
if (backend == null || backend === '' || backend === 'off') {
|
|
97
|
+
const out = write(stateDir, { key: cur.key, repos: cur.repos, local: null });
|
|
98
|
+
if (out.ok) typesafe.configureLocal(null);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
if (!typesafe.validBackend(backend)) return { ok: false, error: 'choose Winnow or Laya' };
|
|
102
|
+
const p = port == null || port === '' ? typesafe.BACKENDS[backend].port : Number(port);
|
|
103
|
+
if (!typesafe.validPort(p)) return { ok: false, error: 'the port must be a whole number from 1024 to 65535' };
|
|
104
|
+
const out = write(stateDir, { key: cur.key, repos: cur.repos, local: { backend, port: p } });
|
|
105
|
+
if (out.ok) typesafe.configureLocal({ backend, port: p });
|
|
106
|
+
return out;
|
|
74
107
|
}
|
|
75
108
|
|
|
76
109
|
function setKey(stateDir, key) {
|
|
77
110
|
const k = typeof key === 'string' ? key.trim() : '';
|
|
78
111
|
if (!validKey(k)) return { ok: false, error: 'that does not look like a key — it must be 8 to 512 characters with no spaces' };
|
|
79
112
|
const cur = read(stateDir);
|
|
80
|
-
return write(stateDir, { key: k, repos: cur.repos });
|
|
113
|
+
return write(stateDir, { key: k, repos: cur.repos, local: cur.local });
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
function clearKey(stateDir) {
|
|
84
117
|
const cur = read(stateDir);
|
|
85
|
-
return write(stateDir, { key: null, repos: cur.repos });
|
|
118
|
+
return write(stateDir, { key: null, repos: cur.repos, local: cur.local });
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
/** Consent and exclusions for one repository root. Consent defaults to off. */
|
|
@@ -115,7 +148,7 @@ function setRepo(stateDir, root, { consent, exclude, watch, jev, categorize, rou
|
|
|
115
148
|
? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
|
|
116
149
|
: prev.exclude;
|
|
117
150
|
const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev, categorize: typeof categorize === 'boolean' ? categorize : prev.categorize, route: typeof route === 'boolean' ? route : prev.route, status_read: typeof statusRead === 'boolean' ? statusRead : prev.status_read } };
|
|
118
|
-
return write(stateDir, { key: cur.key, repos });
|
|
151
|
+
return write(stateDir, { key: cur.key, repos, local: cur.local });
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
/** Does `rel` fall under an exclusion? An entry is a path prefix, or a `*` pattern matched on the path. */
|
|
@@ -130,4 +163,4 @@ function isExcluded(exclude, rel) {
|
|
|
130
163
|
});
|
|
131
164
|
}
|
|
132
165
|
|
|
133
|
-
module.exports = { adoptEnv, status, getKey, setKey, clearKey, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
|
|
166
|
+
module.exports = { adoptEnv, status, getKey, setKey, clearKey, setLocal, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
|
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* Noul "high means yes"; the single `noul` number is P(yes). There is no separate confidence.
|
|
18
18
|
* Score levels 0..N-1; `score` is the probability-weighted mean; `confidence` is reported.
|
|
19
19
|
* Choice one option plus a distribution.
|
|
20
|
+
*
|
|
21
|
+
* LOCAL FALLBACK (D95). When the PM has no TypeSafe key, or Jev cannot answer, the same questions may go to a
|
|
22
|
+
* System One server the PM runs on this machine: Winnow (winnow-inference, port 8091) or Laya (laya-serve, port
|
|
23
|
+
* 8000). Both speak Jev's `POST /v1/systemone`. The host is LOOPBACK, fixed in source like the TypeSafe host; only
|
|
24
|
+
* the port is a setting. A local request carries no key and no model name, and nothing leaves the machine.
|
|
25
|
+
* - `configureLocal({ backend, port })` turns the fallback on for this process; `configureLocal(null)` turns it off.
|
|
26
|
+
* lib/typesafe-settings.js calls it from what the PM saved.
|
|
27
|
+
* - `LOCAL_KEY` is the handle the key store returns when only the local server is set. It is not a secret and
|
|
28
|
+
* it is never sent to TypeSafe: `ask` routes it to loopback before the TypeSafe branch.
|
|
29
|
+
* - With a real key, a TypeSafe failure (unreachable, 401, 429, 5xx) is asked again locally when the fallback is
|
|
30
|
+
* on. The result says `fallback: true` and names the local backend.
|
|
20
31
|
*/
|
|
21
32
|
const https = require('https');
|
|
22
33
|
const http = require('http');
|
|
@@ -28,6 +39,14 @@ const MAX_STATE_CHARS = 60000; // a conservative cap; the documentation st
|
|
|
28
39
|
const MAX_RESPONSE_BYTES = 1 << 20; // 1 MiB — an answer set is small; anything larger is not one
|
|
29
40
|
const MAX_QUESTIONS = 16;
|
|
30
41
|
|
|
42
|
+
const LOCAL_HOST = '127.0.0.1'; // loopback only — the local fallback never reaches another machine (D95)
|
|
43
|
+
const LOCAL_KEY = 'local-systemone'; // the key store's handle for "no TypeSafe key, the local server answers"
|
|
44
|
+
const BACKENDS = {
|
|
45
|
+
winnow: { name: 'Winnow', port: 8091, start: 'python3 scripts/serve.py (in a winnow-inference checkout)' },
|
|
46
|
+
laya: { name: 'Laya', port: 8000, start: 'pip install "laya[serve]" && laya-serve' },
|
|
47
|
+
};
|
|
48
|
+
const LOCAL_ASK = { timeoutMs: 15000, retries: 1, backoffMs: 400 }; // a floor: a laptop model can be slower than the hosted one
|
|
49
|
+
|
|
31
50
|
/* A seam for tests, gated like the sink's. It lets a suite point this module at a loopback stub; it
|
|
32
51
|
* is unreachable in a production process, so no setting can redirect the key to another host. */
|
|
33
52
|
let endpointForTests = null;
|
|
@@ -42,6 +61,19 @@ function endpoint() {
|
|
|
42
61
|
return endpointForTests || { protocol: 'https:', hostname: HOST, port: 443 };
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
function validPort(p) { const n = Number(p); return Number.isInteger(n) && n >= 1024 && n <= 65535; }
|
|
65
|
+
function validBackend(b) { return typeof b === 'string' && Object.prototype.hasOwnProperty.call(BACKENDS, b); }
|
|
66
|
+
|
|
67
|
+
/* The local fallback for this process, or null. Set from the PM's saved choice; never from a request body. */
|
|
68
|
+
let local = null;
|
|
69
|
+
function configureLocal(cfg) {
|
|
70
|
+
if (!cfg || !validBackend(cfg.backend)) { local = null; return null; }
|
|
71
|
+
const port = cfg.port == null || cfg.port === '' ? BACKENDS[cfg.backend].port : Number(cfg.port);
|
|
72
|
+
local = validPort(port) ? { backend: cfg.backend, port } : null;
|
|
73
|
+
return local;
|
|
74
|
+
}
|
|
75
|
+
function localTarget() { return local ? { ...local, name: BACKENDS[local.backend].name } : null; }
|
|
76
|
+
|
|
45
77
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
78
|
const retryable = (status) => status === 429 || status === 529 || (status >= 500 && status < 600);
|
|
47
79
|
|
|
@@ -49,8 +81,7 @@ function validKey(key) {
|
|
|
49
81
|
return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
|
|
50
82
|
}
|
|
51
83
|
|
|
52
|
-
function once({ key, body, timeoutMs }) {
|
|
53
|
-
const ep = endpoint();
|
|
84
|
+
function once({ key, body, timeoutMs, ep = endpoint() }) {
|
|
54
85
|
const mod = ep.protocol === 'http:' ? http : https;
|
|
55
86
|
const payload = Buffer.from(body, 'utf8');
|
|
56
87
|
return new Promise((resolve) => {
|
|
@@ -60,7 +91,7 @@ function once({ key, body, timeoutMs }) {
|
|
|
60
91
|
protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
|
|
61
92
|
headers: {
|
|
62
93
|
'Content-Type': 'application/json', 'Content-Length': payload.length,
|
|
63
|
-
Authorization: 'Bearer ' + key, 'User-Agent': 'pmos-workspace',
|
|
94
|
+
...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace',
|
|
64
95
|
},
|
|
65
96
|
timeout: timeoutMs,
|
|
66
97
|
}, (res) => {
|
|
@@ -104,16 +135,10 @@ function once({ key, body, timeoutMs }) {
|
|
|
104
135
|
* @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
|
|
105
136
|
* `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
|
|
106
137
|
*/
|
|
107
|
-
async function
|
|
108
|
-
if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
|
|
109
|
-
if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
|
|
110
|
-
if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
|
|
111
|
-
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
112
|
-
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
113
|
-
const body = JSON.stringify({ state, model, questions });
|
|
138
|
+
async function send({ key, body, ep, timeoutMs, retries, backoffMs }) {
|
|
114
139
|
let last = null;
|
|
115
140
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
116
|
-
last = await once({ key, body, timeoutMs });
|
|
141
|
+
last = await once({ key, body, timeoutMs, ep });
|
|
117
142
|
if (last.ok) return { ...last, requested: true };
|
|
118
143
|
if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
|
|
119
144
|
await sleep(backoffMs * Math.pow(2, attempt));
|
|
@@ -121,4 +146,40 @@ async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 3
|
|
|
121
146
|
return { ok: false, requested: true, status: last.status, reason: last.reason };
|
|
122
147
|
}
|
|
123
148
|
|
|
124
|
-
|
|
149
|
+
/* A local server may leave out an answer's `type`; the bridges read it, so it is filled from the question. */
|
|
150
|
+
function withTypes(answers, questions) {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const [n, a] of Object.entries(answers || {})) {
|
|
153
|
+
out[n] = a && typeof a === 'object' && a.type == null && questions[n] ? { ...a, type: questions[n].type } : a;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Ask the PM's local System One server. No key and no model name go with it: each server answers with its own model. */
|
|
159
|
+
async function askLocal({ state, questions, timeoutMs, retries, backoffMs }) {
|
|
160
|
+
const t = localTarget();
|
|
161
|
+
if (!t) return { ok: false, requested: false, reason: 'no local model is set' };
|
|
162
|
+
const r = await send({ key: null, body: JSON.stringify({ state, questions }), ep: { protocol: 'http:', hostname: LOCAL_HOST, port: t.port },
|
|
163
|
+
timeoutMs: Math.max(timeoutMs || 0, LOCAL_ASK.timeoutMs), retries: Math.min(retries == null ? LOCAL_ASK.retries : retries, LOCAL_ASK.retries), backoffMs: backoffMs || LOCAL_ASK.backoffMs });
|
|
164
|
+
if (!r.ok) return { ...r, local: t.backend, reason: `${t.name} on this machine: ` + r.reason.replace(/^the service/, 'the server') };
|
|
165
|
+
return { ...r, answers: withTypes(r.answers, questions), model: r.model || t.backend, local: t.backend };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* When the hosted answer fails this way, the local server is asked instead (D95). A malformed request (422) is not. */
|
|
169
|
+
const failsOver = (r) => r.requested && (r.status === 0 || r.status === 401 || retryable(r.status));
|
|
170
|
+
|
|
171
|
+
async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
|
|
172
|
+
if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
|
|
173
|
+
if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
|
|
174
|
+
if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
|
|
175
|
+
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
176
|
+
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
177
|
+
// The local handle never reaches TypeSafe: it is routed to loopback here, before the hosted branch.
|
|
178
|
+
if (key === LOCAL_KEY) return askLocal({ state, questions, timeoutMs, retries, backoffMs });
|
|
179
|
+
const r = await send({ key, body: JSON.stringify({ state, model, questions }), ep: endpoint(), timeoutMs, retries, backoffMs });
|
|
180
|
+
if (r.ok || !local || !failsOver(r)) return r;
|
|
181
|
+
const l = await askLocal({ state, questions, timeoutMs, retries, backoffMs });
|
|
182
|
+
return l.ok ? { ...l, fallback: true, hosted_reason: r.reason } : { ...r, reason: r.reason + '; ' + l.reason };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { ask, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.166.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.166.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* tag, recording or paused, "no meeting", and a dot for a conflict.
|
|
9
9
|
* the head the note's meeting, then what it is about (the Portfolio, a board, or an initiative on its board) with one picker
|
|
10
10
|
* (aboutMenu: /api/boards and each board's Now and next initiatives), the mockup the PM approved on 2026-10-12.
|
|
11
|
+
* Copy as Markdown, and Delete (asked first; remove), as the main app's Notes has.
|
|
11
12
|
* the note the Calendar stage's layout (calendar-views.js stageNode, the mockup the PM approved on 2026-10-12): a head with
|
|
12
13
|
* Before, During and After, then the stage's cards.
|
|
13
14
|
* Before the meeting context the note kept (meeting.context): agenda (an item heard is ticked), what it is for, to be ready,
|
|
@@ -35,6 +36,7 @@ import * as dock from './note-dock.js';
|
|
|
35
36
|
import * as V from './view-notes.js';
|
|
36
37
|
import { makeTask } from './notes-task.js';
|
|
37
38
|
import { screenStrip } from './notes-screen-view.js';
|
|
39
|
+
import { askConfirm } from './dialogs.js';
|
|
38
40
|
|
|
39
41
|
const PHASES = [['before', 'clip', 'Before'], ['during', 'mic', 'During'], ['after', 'notes', 'After']];
|
|
40
42
|
const startOf = (n) => (n.meeting && n.meeting.start) || n.recorded_from || n.created_at || n.updated_at;
|
|
@@ -199,7 +201,8 @@ function noteCol(s) {
|
|
|
199
201
|
PHASES.map(([k, ic, l]) => btn('sgb', { 'data-cal3-phase': k, 'aria-pressed': String(ph === k), title: l }, [c3ic(ic), el('span', null, l)], () => { s.phase = k; draw(s); })))),
|
|
200
202
|
el('div', { class: 'chips2 dim cal3-sm' },
|
|
201
203
|
[dayOf(startOf(n)), len].filter(Boolean).join(' · '),
|
|
202
|
-
btn('ib sm', { title: 'Copy as Markdown (with the meeting context)', 'aria-label': 'Copy as Markdown', 'data-cal3-notes': 'copy' }, c3ic('clip'), () => copy(n))
|
|
204
|
+
btn('ib sm', { title: 'Copy as Markdown (with the meeting context)', 'aria-label': 'Copy as Markdown', 'data-cal3-notes': 'copy' }, c3ic('clip'), () => copy(n)),
|
|
205
|
+
btn('ib sm', { title: 'Delete this note', 'aria-label': 'Delete note', 'data-cal3-notes': 'delete' }, c3ic('trash'), () => remove(s, n))),
|
|
203
206
|
links(s, n, ev));
|
|
204
207
|
const body = ph === 'before' ? before(s, n) : ph === 'during' ? during(s, n, mode) : after(s, n, mode);
|
|
205
208
|
return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'note', 'data-cal3-note': n.id, 'data-cal3-stage': ph }, head, ...[].concat(body));
|
|
@@ -444,6 +447,21 @@ async function copy(n) {
|
|
|
444
447
|
catch { toast('The clipboard was not available.'); }
|
|
445
448
|
}
|
|
446
449
|
|
|
450
|
+
/* Delete: asked first, as the main app's Notes asks (view-notes.js deleteNote). A note being recorded stops without keeping its
|
|
451
|
+
* audio, then the note, its transcript, audio and screenshots go (POST /api/notes/delete). The next note in the list opens. */
|
|
452
|
+
async function remove(s, n) {
|
|
453
|
+
const name = n.title || 'Untitled note';
|
|
454
|
+
const sure = await askConfirm({ title: 'Delete "' + name + '"?', body: 'The note, its transcript, its summaries and its kept audio are deleted from this machine. Its summary goes from the calendar and the initiative. This cannot be undone.', confirmLabel: 'Delete note', cancelLabel: 'Keep it', danger: true });
|
|
455
|
+
if (!sure) return;
|
|
456
|
+
if (!(await V.discardRecording({ state: {}, setState: () => {} }, n.id))) { toast('"' + name + '" was not deleted.'); return; }
|
|
457
|
+
dock.forget(n.id);
|
|
458
|
+
s.rows = (s.rows || []).filter((x) => x.id !== n.id);
|
|
459
|
+
s.open = null; s.note = null; s.phase = null; s.shots = []; s.rec = null; s.audio = null;
|
|
460
|
+
toast('"' + name + '" was deleted.');
|
|
461
|
+
refreshNotes();
|
|
462
|
+
read(s);
|
|
463
|
+
}
|
|
464
|
+
|
|
447
465
|
/* "Show in the calendar": the Calendar switch back to the grid, on the meeting's day, with the meeting selected */
|
|
448
466
|
function showMeeting(s, ev) {
|
|
449
467
|
const ui = s.ui; if (!ui) return;
|
|
@@ -587,19 +587,47 @@ export function mountSettings(host, ctx = {}) {
|
|
|
587
587
|
['Update check', 'Asks the npm registry for the latest version.', val('upd.check') === 'daily' ? 'Once a day' : 'Only when you press Check', val('upd.check') === 'daily' ? 'warn' : ''],
|
|
588
588
|
['Online tunnel', 'Cloudflare carries your pages to your other devices (D89).', o && o.on ? 'On' : 'Off', o && o.on ? 'warn' : ''],
|
|
589
589
|
['Jev (TypeSafe)', 'Note lines and matching passages. Never audio, names or private notes (D79).', M.ws ? (jevOn ? 'On in ' : 'Off in ') + wsName(M.ws) : 'Pick a workspace', jevOn ? 'warn' : ''],
|
|
590
|
+
['Local model', 'Winnow or Laya on 127.0.0.1 answers Jev questions when there is no TypeSafe key or TypeSafe fails (D95).', M.typesafe && M.typesafe.local ? M.typesafe.local.name + ', stays here' : 'Off', 'ok'],
|
|
590
591
|
['Your connected tools', 'Calendar, Jira, Wispr Flow and the sender are reached by your Claude session, not by PMOS.', 'Through profile ' + (profileNow().id || 'none'), ''],
|
|
591
592
|
];
|
|
592
593
|
const leave = L.map(([t, h, s, tone]) => el('li', { class: 'nrow' }, el('span', { class: cx('ki', tone === 'ok' ? 'hot' : tone === 'warn' ? 't-warn' : '') }, c3ic(tone === 'ok' ? 'lock' : 'out')),
|
|
593
594
|
el('div', { class: 'nb' }, el('span', { class: 'tt' }, t), help(h)), el('div', { class: 'acts' }, tag(s, tone))));
|
|
594
595
|
return [sect('What leaves this computer', [list(leave)], { ic: 'shield', attrs: { 'data-c3set-leaves': '' } }),
|
|
595
596
|
sect('Keys and files', [list([
|
|
596
|
-
frame({ key: 'typesafe', label: 'TypeSafe key', help: 'Kept on this computer only. It is never shown, here or anywhere.', scope: 'computer', control: M.typesafe ? tag(M.typesafe.
|
|
597
|
+
frame({ key: 'typesafe', label: 'TypeSafe key', help: 'Kept on this computer only. It is never shown, here or anywhere.', scope: 'computer', control: M.typesafe ? tag(M.typesafe.source === 'settings' || M.typesafe.source === 'environment' ? 'Saved' : 'None', M.typesafe.source === 'settings' || M.typesafe.source === 'environment' ? 'ok' : '') : tag('Reading') }),
|
|
598
|
+
localModelRow(),
|
|
597
599
|
frame({ key: 'state', label: 'Data folder', help: 'Notes, audio, settings and caches. Outside every repository, so no commit can carry them.', scope: 'computer', control: chip((M.data && M.data.state_dir) || '', 'mono st-wrap-text') }),
|
|
598
600
|
lockedRow('priv.cards', 'Send card text to Jev for icons', 'Card kinds and icons are chosen by code rules on this computer (D7).', 'Off', 'computer'),
|
|
599
601
|
notYet('priv.export', 'Export or reset every setting', 'Save every setting to a file, or put them all back to their defaults. Your workspaces, notes and audio stay.', 'Export and reset all come later. Reset one setting or one section with its Reset button.', 'computer')]),
|
|
600
602
|
el('div', { class: 'iacts' }, btn('Export settings', { ic: 'out', disabled: true }), btn('Reset all settings', { variant: 'ghost', disabled: true }), w && w.keep_audio !== false ? btn('Recording settings', { variant: 'ghost', ic: 'next', onclick: () => goSec('recording') }) : null)], { ic: 'lock' })];
|
|
601
603
|
}
|
|
602
604
|
|
|
605
|
+
/* D95 — a model on this computer answers Jev's questions when there is no TypeSafe key, or when TypeSafe fails.
|
|
606
|
+
* Winnow and Laya both speak Jev's interface; PMOS only calls 127.0.0.1 on the port shown. */
|
|
607
|
+
function localModelRow() {
|
|
608
|
+
const t = M.typesafe; const loc = t && t.local; const cur = loc ? loc.backend : 'off';
|
|
609
|
+
const pick = async (v) => {
|
|
610
|
+
const r = await pmPost('/api/typesafe/local', { backend: v });
|
|
611
|
+
if (r && r.ok !== false) { M.typesafe = r.key; delete M.err['typesafe.local']; M.localTest = null; toast(v === 'off' ? 'Saved: no local model' : 'Saved: local model'); }
|
|
612
|
+
else M.err['typesafe.local'] = (r && r.error) || 'Not saved.';
|
|
613
|
+
draw();
|
|
614
|
+
};
|
|
615
|
+
const test = async () => {
|
|
616
|
+
M.localTest = { busy: true }; draw();
|
|
617
|
+
const r = await pmPost('/api/typesafe/local/test', {});
|
|
618
|
+
M.localTest = r && r.ok ? { ok: true, text: 'Answered (' + (r.model || r.local) + ').' } : { ok: false, text: (r && r.error) || 'No answer.' };
|
|
619
|
+
draw();
|
|
620
|
+
};
|
|
621
|
+
const lt = M.localTest;
|
|
622
|
+
const note = !loc ? 'Off. With no TypeSafe key, Jev features stay off.'
|
|
623
|
+
: (t.source === 'local' ? 'No TypeSafe key, so ' + loc.name + ' answers every Jev question.' : 'TypeSafe answers first; ' + loc.name + ' answers when it fails.')
|
|
624
|
+
+ ' PMOS calls 127.0.0.1:' + loc.port + ' only. Nothing leaves this computer.' + (lt && !lt.busy ? ' ' + lt.text : '');
|
|
625
|
+
return frame({ key: 'typesafe.local', label: 'Local model if Jev is missing', help: 'Start Winnow (winnow-inference, port 8091) or Laya (laya-serve, port 8000) on this computer, then pick it here.', scope: 'computer',
|
|
626
|
+
err: M.err['typesafe.local'], note,
|
|
627
|
+
control: t ? el('div', { class: 'iacts' }, seg('Local model', [['off', 'Off'], ['winnow', 'Winnow'], ['laya', 'Laya']], cur, pick),
|
|
628
|
+
loc ? btn(lt && lt.busy ? 'Testing' : 'Test', { variant: 'ghost', disabled: !!(lt && lt.busy), onclick: test }) : null) : tag('Reading') });
|
|
629
|
+
}
|
|
630
|
+
|
|
603
631
|
function appearanceSec() {
|
|
604
632
|
const pref = ctx.themePref || ctx.theme;
|
|
605
633
|
return [group('Look', [extRow('look.theme', seg('Theme', [['system', 'Same as system'], ['light', 'Light'], ['dark', 'Dark']], pref, (v) => { const before = ctx.themePref || ctx.theme; if (ctx.setTheme) ctx.setTheme(v); ctx.themePref = v; ctx.theme = v; toast('Saved: theme', () => { if (ctx.setTheme) ctx.setTheme(before); ctx.themePref = before; ctx.theme = before; draw(); }); draw(); }), { scope: 'computer', note: 'Kept in this browser.' }),
|
|
@@ -162,6 +162,17 @@
|
|
|
162
162
|
"names": [
|
|
163
163
|
"(d) the Tree pane shows at most ${TREE_BUDGET} controls on first paint (specimen A's own Tree view)"
|
|
164
164
|
]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"id": "STD-LOCAL",
|
|
168
|
+
"file": "workspace/typesafe-validation.test.js",
|
|
169
|
+
"run": "jev-local-fallback",
|
|
170
|
+
"ref": "DECISIONS.md D95 and planning/initiatives/jev-local-fallback.md (PM wawan, 2026-10-12)",
|
|
171
|
+
"reason": "D95 adds the local fallback server to the key store's status, so status() gains a `local` field (null when none is set). B1 and B5 compared the exact JSON; they keep their intent (absent, then present from settings, never the key) with `\"local\":null` in the expected text.",
|
|
172
|
+
"names": [
|
|
173
|
+
"B1 with nothing stored, status says absent",
|
|
174
|
+
"B5 status never carries the key or its length"
|
|
175
|
+
]
|
|
165
176
|
}
|
|
166
177
|
],
|
|
167
178
|
"entries": [
|
package/workspace/server.js
CHANGED
|
@@ -238,6 +238,7 @@ const STATE_RESOLVED = stateRoot.resolveStateRoot({
|
|
|
238
238
|
legacy: REQUIRED_AS_LIBRARY ? null : LEGACY_STATE,
|
|
239
239
|
});
|
|
240
240
|
const STATE = STATE_RESOLVED.root;
|
|
241
|
+
tsSettings.status(STATE); // D95 — load the saved local fallback (Winnow or Laya) into the TypeSafe module
|
|
241
242
|
// ── AN UNSETTLED STATE ROOT MUST NOT DEGRADE INTO "YOU HAVE NOTHING". ─────────────────────────
|
|
242
243
|
// When both directories hold state nothing is moved (only a human can decide which sixteen
|
|
243
244
|
// sessions are the real sixteen) — but the stores the per-user root does NOT hold used to resolve
|
|
@@ -7965,6 +7966,22 @@ async function handleRequest(req, res) {
|
|
|
7965
7966
|
applyOnline();
|
|
7966
7967
|
return json(res, onlineView(req));
|
|
7967
7968
|
}
|
|
7969
|
+
/* D95 — the local fallback: a System One server on this machine (Winnow or Laya) answers when there is no TypeSafe key,
|
|
7970
|
+
* or when TypeSafe fails. Loopback only; the port is the one setting. `test` asks it one question about a fixed sentence. */
|
|
7971
|
+
if (p === '/api/typesafe/local' && req.method === 'POST') {
|
|
7972
|
+
let body = {};
|
|
7973
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
7974
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
7975
|
+
const out = tsSettings.setLocal(STATE, { backend: body.backend, port: body.port }); // backend 'off' clears it
|
|
7976
|
+
if (!out.ok) return json(res, { ok: false, error: out.error }, /choose|port/.test(out.error) ? 400 : 500);
|
|
7977
|
+
return json(res, { ok: true, key: tsSettings.status(STATE) });
|
|
7978
|
+
}
|
|
7979
|
+
if (p === '/api/typesafe/local/test' && req.method === 'POST') {
|
|
7980
|
+
if (!tsSettings.status(STATE).local) return json(res, { ok: false, error: 'No local model is set.' }, 409);
|
|
7981
|
+
const r = await tsClient.ask({ key: tsClient.LOCAL_KEY, state: 'PMOS is checking that the local model answers.', retries: 0,
|
|
7982
|
+
questions: { check: { type: 'noul', instructions: 'Is this sentence a connection check?' } } });
|
|
7983
|
+
return json(res, r.ok ? { ok: true, model: r.model, local: r.local } : { ok: false, error: r.reason }, r.ok ? 200 : 502);
|
|
7984
|
+
}
|
|
7968
7985
|
if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
7969
7986
|
let body = {};
|
|
7970
7987
|
if (req.method === 'PUT') {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"story-artifacts.test.js",
|
|
23
23
|
"no-network.test.js",
|
|
24
24
|
"typesafe-validation.test.js",
|
|
25
|
+
"typesafe-local.test.js",
|
|
25
26
|
"claims.test.js",
|
|
26
27
|
"pm-items.test.js",
|
|
27
28
|
"jobs-to-be-done.test.js",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"story-artifacts.test.js": 1,
|
|
157
158
|
"no-network.test.js": 1,
|
|
158
159
|
"typesafe-validation.test.js": 1,
|
|
160
|
+
"typesafe-local.test.js": 1,
|
|
159
161
|
"claims.test.js": 1,
|
|
160
162
|
"pm-items.test.js": 1,
|
|
161
163
|
"jobs-to-be-done.test.js": 1,
|