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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.164.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@2f861db
1
+ kernel-source: pmos@4ae0213
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T12:54:32Z"}
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-12T12:54:32Z"}
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-12T12:54:32Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T13:44:56Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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-04
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 { validKey } = require('./typesafe');
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 f = read(stateDir).key;
65
- if (validKey(f)) return f;
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
- if (validKey(read(stateDir).key)) return { present: true, source: 'settings' };
72
- if (envKey(env)) return { present: true, source: 'environment' };
73
- return { present: false, source: null };
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 ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
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
- module.exports = { ask, validKey, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, __setEndpointForTests };
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.164.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.164.0",
9
+ "version": "0.166.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.164.0",
3
+ "version": "0.166.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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.present ? 'Saved' : 'None', M.typesafe.present ? 'ok' : '') : tag('Reading') }),
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": [
@@ -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,