create-pmos 0.164.0 → 0.165.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.165.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@574eff4
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:19:10Z"}
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:19:10Z"}
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:19:10Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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.165.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.165.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.165.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",
@@ -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,