create-pmos 0.38.7 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +64 -1
  7. package/workspace/bin/pmos.js +10 -1
  8. package/workspace/guide/index.md +38 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +59 -0
  11. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  12. package/workspace/guide/statuses.md +53 -0
  13. package/workspace/guide/validate-with-typesafe.md +47 -0
  14. package/workspace/lib/initiative-files.js +134 -0
  15. package/workspace/lib/judgment.js +65 -0
  16. package/workspace/lib/mobile-nav.js +1 -1
  17. package/workspace/lib/next-step-actions.js +139 -0
  18. package/workspace/lib/next-steps.js +133 -0
  19. package/workspace/lib/prompt-input.js +18 -0
  20. package/workspace/lib/schedule-cli.js +259 -0
  21. package/workspace/lib/schedule-logic.js +23 -1
  22. package/workspace/lib/secret-scan.js +28 -0
  23. package/workspace/lib/session-budget.js +35 -0
  24. package/workspace/lib/skill-store.js +11 -3
  25. package/workspace/lib/status-groups.js +109 -0
  26. package/workspace/lib/transcripts.js +73 -0
  27. package/workspace/lib/typesafe-settings.js +119 -0
  28. package/workspace/lib/typesafe.js +124 -0
  29. package/workspace/lib/validate.js +304 -0
  30. package/workspace/package-lock.json +1 -1
  31. package/workspace/package.json +3 -3
  32. package/workspace/public/index.html +2 -0
  33. package/workspace/public/pm/app.js +53 -18
  34. package/workspace/public/pm/data.js +41 -5
  35. package/workspace/public/pm/mobile.js +1 -1
  36. package/workspace/public/pm/modes-live.js +171 -0
  37. package/workspace/public/pm/modes.js +25 -0
  38. package/workspace/public/pm/next-step-ui.js +185 -0
  39. package/workspace/public/pm/panel-detail.js +121 -1
  40. package/workspace/public/pm/panel-initiatives.js +61 -3
  41. package/workspace/public/pm/panel-repos.js +2 -2
  42. package/workspace/public/pm/panel-tabs.js +38 -2
  43. package/workspace/public/pm/panel-trees.js +9 -2
  44. package/workspace/public/pm/state.js +9 -4
  45. package/workspace/public/pm/status-banner.js +87 -0
  46. package/workspace/public/pm/tab-doc.js +11 -6
  47. package/workspace/server.js +434 -8
@@ -86,10 +86,25 @@ function nextSlot(schedule, now) {
86
86
  }
87
87
  function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
88
88
 
89
+ // R5 — an optional end. A schedule ends when it has run `max_runs` times or when the day after `until` begins
90
+ // (the `until` day itself still runs). No `until` and no `max_runs` means it never ends on its own.
91
+ function endOfDay(dateStr) {
92
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateStr || ''));
93
+ return m ? new Date(+m[1], +m[2] - 1, +m[3] + 1, 0, 0, 0, 0) : null;
94
+ }
95
+ function finishReason(schedule, now = new Date()) {
96
+ if (!schedule) return null;
97
+ const max = +schedule.max_runs;
98
+ if (max > 0 && (+schedule.runs || 0) >= max) return `max_runs reached (${max} run${max === 1 ? '' : 's'})`;
99
+ const end = endOfDay(schedule.until);
100
+ if (end && now >= end) return `until ${schedule.until} has passed`;
101
+ return null;
102
+ }
89
103
  // Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
90
104
  // require ≥ 13 days since lastRun (weekly slots, fired every other week).
91
105
  function isDue(schedule, now = new Date()) {
92
106
  if (!schedule || !schedule.enabled) return false;
107
+ if (finishReason(schedule, now)) return false;
93
108
  const slot = lastSlot(schedule, now);
94
109
  if (!slot) return false;
95
110
  const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
@@ -130,6 +145,11 @@ function normalizeSchedule(input) {
130
145
  if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
131
146
  if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
132
147
  if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
148
+ if (s.until != null && s.until !== '') {
149
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.until)) || !endOfDay(s.until) || Number.isNaN(endOfDay(s.until).getTime())) return { ok: false, error: 'until must be a date, YYYY-MM-DD' };
150
+ if (endOfDay(s.until) <= new Date()) return { ok: false, error: 'until is already in the past — the schedule would never run' };
151
+ }
152
+ if (s.max_runs != null && s.max_runs !== '' && !(Number.isInteger(+s.max_runs) && +s.max_runs >= 1 && +s.max_runs <= 10000)) return { ok: false, error: 'max_runs must be a whole number from 1 to 10000' };
133
153
  if (s.cadence === 'once') {
134
154
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.date || ''))) return { ok: false, error: 'a one-off needs a date (YYYY-MM-DD)' };
135
155
  const slot = onceSlot({ date: s.date, time: s.time });
@@ -153,9 +173,11 @@ function normalizeSchedule(input) {
153
173
  task: s.task || s.skill,
154
174
  skill: String(s.skill).trim(),
155
175
  enabled: s.enabled !== false,
176
+ ...(s.until ? { until: String(s.until) } : {}),
177
+ ...(s.max_runs ? { max_runs: Math.floor(+s.max_runs) } : {}),
156
178
  },
157
179
  };
158
180
  }
159
181
  function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
160
182
 
161
- module.exports = { CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
183
+ module.exports = { finishReason, endOfDay, CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ /* secret-scan.js — a local check that runs BEFORE any repository text is sent anywhere (D79, V7).
3
+ *
4
+ * It returns the NAMES of the rules that matched and never the matched text, so a finding or a log
5
+ * line built from the result cannot carry the secret. A green scan is not proof that a text is
6
+ * clean: the per-repository consent switch is the real control, and this is the second line.
7
+ */
8
+ const RULES = [
9
+ ['private-key-block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
10
+ ['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
11
+ ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/],
12
+ ['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
13
+ ['api-key-sk', /\bsk[-_](?:live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{20,}\b/],
14
+ ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/],
15
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/],
16
+ ['url-with-password', /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{3,}@[^\s/]+/i],
17
+ ['assigned-secret', /\b(?:api[_-]?key|secret|token|passwd|password)\b["']?\s*[:=]\s*["']?[A-Za-z0-9/+_.\-]{16,}/i],
18
+ ];
19
+
20
+ /** @returns {string[]} names of the rules that matched; empty when none did. */
21
+ function scan(text) {
22
+ const t = String(text == null ? '' : text);
23
+ const hits = [];
24
+ for (const [name, re] of RULES) if (re.test(t)) hits.push(name);
25
+ return hits;
26
+ }
27
+
28
+ module.exports = { scan, RULES: RULES.map(([n]) => n) };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ /* session-budget.js — how many sessions the workspace may start on the PM's behalf.
3
+ *
4
+ * Only sessions started from a next step (origin `next-step`) are counted and limited. A session the PM starts by hand
5
+ * is never counted and never blocked: the budget protects against the workspace spending, not against the PM working.
6
+ * Pure: the caller passes what is running and what started today.
7
+ */
8
+ const DEFAULTS = Object.freeze({ concurrent: 3, perDay: 8 });
9
+ const RANGE = { concurrent: [1, 20], perDay: [1, 100] };
10
+
11
+ /** @returns {{ok:true,limits}|{ok:false,error}} */
12
+ function normalizeLimits(v) {
13
+ const raw = v && typeof v === 'object' ? v : {};
14
+ const out = { ...DEFAULTS };
15
+ for (const k of ['concurrent', 'perDay']) {
16
+ if (raw[k] === undefined) continue;
17
+ const n = Number(raw[k]);
18
+ if (!Number.isInteger(n) || n < RANGE[k][0] || n > RANGE[k][1]) return { ok: false, error: `${k === 'perDay' ? 'per day' : k} must be a whole number from ${RANGE[k][0]} to ${RANGE[k][1]}` };
19
+ out[k] = n;
20
+ }
21
+ return { ok: true, limits: out };
22
+ }
23
+
24
+ /** @param {{running:number, startedToday:number, limits?:object}} s */
25
+ function check(s) {
26
+ const n = normalizeLimits(s && s.limits);
27
+ const limits = n.ok ? n.limits : { ...DEFAULTS };
28
+ const running = Math.max(0, Number(s && s.running) || 0);
29
+ const startedToday = Math.max(0, Number(s && s.startedToday) || 0);
30
+ if (running >= limits.concurrent) return { allowed: false, reason: `${running} prepared sessions are already running (limit ${limits.concurrent}). End one or raise the limit.`, running, startedToday, limits };
31
+ if (startedToday >= limits.perDay) return { allowed: false, reason: `${startedToday} sessions were started today (limit ${limits.perDay}). Raise the limit to start more.`, running, startedToday, limits };
32
+ return { allowed: true, reason: null, running, startedToday, limits };
33
+ }
34
+
35
+ module.exports = { DEFAULTS, normalizeLimits, check };
@@ -266,7 +266,7 @@ function retargetFiling(text, filing) {
266
266
  // Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
267
267
  // or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
268
268
  // claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
269
- function scaffoldSkill({ name, description, output, body, filing } = {}) {
269
+ function scaffoldSkill({ name, description, output, body, filing, work } = {}) {
270
270
  const slug = slugify(name);
271
271
  if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
272
272
  const label = titleCase(name) || slug;
@@ -276,7 +276,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
276
276
  if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
277
277
  // A hand-written body is retargeted too: a PM pasting the old contract text in gets a skill that
278
278
  // files where the console reads, not one that quietly writes into a directory nobody opens.
279
- const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing);
279
+ const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing, work ? String(work).slice(0, 8000) : null);
280
280
  const content = [
281
281
  '---',
282
282
  `name: ${slug}`,
@@ -306,7 +306,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
306
306
  // suite could not reach it. A scaffold that still said "workspace/state/deliverables/" would send
307
307
  // every scheduled agent's report to a directory the console never reads: a silent, permanent
308
308
  // no-show in the needs-you queue. The defaults keep this module usable standalone.
309
- function defaultBody(label, desc, out, slug, filing) {
309
+ function defaultBody(label, desc, out, slug, filing, work) {
310
310
  const id = slug || slugify(label) || 'run';
311
311
  const deliverables = (filing && filing.deliverables) || 'workspace/state/deliverables';
312
312
  const needs = (filing && filing.needs) || 'workspace/state/needs.json';
@@ -318,9 +318,17 @@ function defaultBody(label, desc, out, slug, filing) {
318
318
  '',
319
319
  'Procedure — run the numbered steps in order; each ends on a checkable done-condition:',
320
320
  '',
321
+ ...(work ? [
322
+ '1. Repeat the work below. It was captured from an earlier terminal session by the PM: do the same job again,',
323
+ " with today's dates and this repository's current files. Do not repeat one-off details (old names, old dates, old errors).",
324
+ ' Done when: the same kind of result exists for today, each statement traceable to a real source.',
325
+ ' Captured work:',
326
+ ...String(work).split('\n').map((l) => ' | ' + l),
327
+ ] : [
321
328
  "1. Gather inputs from this repo's own local sources — name the exact commands/paths the run",
322
329
  ' depends on (e.g. `git log`, `gh pr view <n> --json title,body`, a file under `planning/`).',
323
330
  ' Done when: every input the sections below need is in hand, each traceable to a real source.',
331
+ ]),
324
332
  '2. Produce each required section from those inputs, in the exact order of the output contract.',
325
333
  ' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
326
334
  ' Done when: every contract section is filled or explicitly "None this period.".',
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ // Grouping the initiative list by status (initiative-surface-batch R2).
3
+ //
4
+ // The status is not typed by anyone: the server derives it for every initiative from the ledgers and
5
+ // git (`deriveState` in server.js — stopped, accepted, blocked, delivered, building, planning, unknown)
6
+ // and, for a stopped one, the lifecycle ledger says which kind (on-hold, closed, cancelled). This maps
7
+ // that derived pair to the PM's words and puts the groups in one order. It reads the derived value and
8
+ // nothing else, so a hand-typed `status:` in a record can never decide where a row lands, and an
9
+ // unreadable history is its own group instead of passing for Planning.
10
+ //
11
+ // Classic script (a UMD setting window.PMOSStatusGroups) like the other lib/ files the board shares
12
+ // with node, so the order and the mapping are walkable in the test suite.
13
+ (function (root, factory) {
14
+ const api = factory();
15
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
16
+ else root.PMOSStatusGroups = api;
17
+ }(typeof self !== 'undefined' ? self : this, function () {
18
+
19
+ // In the order the PM reads them: what needs them, what is moving, what has not started, what waits on
20
+ // their acceptance, what is done, then what is parked. `active` marks the four that are live work.
21
+ // Every group states five things, so the legend on the screen and the grouping cannot disagree:
22
+ // meaning what the PM is being told
23
+ // source the fact it is read from (a ledger line or git), never a typed field
24
+ // owner who has to act: pm | agent | system
25
+ // moves what takes an initiative out of this group, and where it goes
26
+ // pmCan what the PM can do by hand here (only hold, close, cancel and reopen are written; the rest is derived)
27
+ const GROUPS = [
28
+ { key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue',
29
+ meaning: 'Work is stopped on a decision or an answer only you can give.',
30
+ source: 'acceptances.jsonl (your latest acceptance row is a return) or the needs queue (an open item names this initiative)',
31
+ owner: 'pm', moves: 'When the open items are cleared, or you accept after the agent addresses the return, it moves to In progress or Awaiting acceptance.',
32
+ pmCan: 'Answer the items listed in the detail. You can also hold, close or cancel it.' },
33
+ { key: 'in-progress', label: 'In progress', active: true, hint: 'runs logged, nothing merged yet',
34
+ meaning: 'An agent run is logged and nothing has been merged yet.',
35
+ source: 'agent-runs.jsonl has at least one run for it, and no initiative/<id> branch is merged',
36
+ owner: 'agent', moves: 'When its branch is merged it moves to Awaiting acceptance. An open needs item moves it to Needs you.',
37
+ pmCan: 'Read the next steps and the conversations. You can hold, close or cancel it.' },
38
+ { key: 'planning', label: 'Planning', active: true, hint: 'recorded, no run logged yet',
39
+ meaning: 'The initiative is recorded and no agent run has started.',
40
+ source: 'the initiative record exists; agent-runs.jsonl has no run for it',
41
+ owner: 'agent', moves: 'When the first run is logged it moves to In progress.',
42
+ pmCan: 'Review what the agent drafts. You can also hold, close or cancel it.' },
43
+ { key: 'awaiting-acceptance', label: 'Awaiting acceptance', active: true, hint: 'delivered; the PM has not accepted it',
44
+ meaning: 'The work is delivered and waits for your acceptance. Acceptance is yours alone (D12).',
45
+ source: 'git: an initiative/<id> branch was merged, or a trunk commit names the initiative and changes build files; no acceptance row yet',
46
+ owner: 'pm', moves: 'When you record an acceptance it moves to Accepted. If you return it, it moves to Needs you.',
47
+ pmCan: 'Open the Acceptance Gate and accept or return it. You can also hold, close or cancel it.' },
48
+ { key: 'accepted', label: 'Accepted', active: false, hint: 'accepted by the PM',
49
+ meaning: 'You accepted it. Nothing remains.',
50
+ source: 'acceptances.jsonl (your latest acceptance row accepts)',
51
+ owner: 'system', moves: 'It stays here.',
52
+ pmCan: 'Nothing to do. Close it only if you want it off the board.' },
53
+ { key: 'on-hold', label: 'On hold', active: false, hint: 'paused by the PM; off the board, not gating',
54
+ meaning: 'You paused it. It is off the board and does not block anything.',
55
+ source: 'lifecycle.jsonl (your latest line for it is on-hold)',
56
+ owner: 'pm', moves: 'It stays here until you reopen it. It then returns to the group its facts give.',
57
+ pmCan: 'Reopen it.' },
58
+ { key: 'closed', label: 'Closed', active: false, hint: 'closed by the PM; off the board, not gating',
59
+ meaning: 'You closed it. It is off the board and does not block anything.',
60
+ source: 'lifecycle.jsonl (your latest line for it is closed)',
61
+ owner: 'pm', moves: 'It stays here until you reopen it.',
62
+ pmCan: 'Reopen it.' },
63
+ { key: 'cancelled', label: 'Cancelled', active: false, hint: 'cancelled by the PM; off the board, not gating',
64
+ meaning: 'You cancelled it. It is off the board and does not block anything.',
65
+ source: 'lifecycle.jsonl (your latest line for it is cancelled)',
66
+ owner: 'pm', moves: 'It stays here until you reopen it.',
67
+ pmCan: 'Reopen it.' },
68
+ { key: 'unknown', label: 'Unknown', active: false, hint: 'the history could not be read, so the status cannot be derived',
69
+ meaning: 'The status could not be worked out, so none is shown.',
70
+ source: 'the trunk history or the ledgers could not be read',
71
+ owner: 'system', moves: 'When the history can be read again it moves to the group its facts give.',
72
+ pmCan: 'Check that the repository is readable, then reload.' },
73
+ ];
74
+ const LEGEND_FIELDS = ['meaning', 'source', 'owner', 'moves', 'pmCan'];
75
+ const BY_KEY = Object.fromEntries(GROUPS.map((g) => [g.key, g]));
76
+
77
+ /** The group key for one derived status row `{status, lifecycle}`; `unknown` when the row is missing or unrecognised. */
78
+ function keyOf(row) {
79
+ if (!row || typeof row !== 'object') return 'unknown';
80
+ switch (row.status) {
81
+ case 'blocked': return 'needs-you';
82
+ case 'building': return 'in-progress';
83
+ case 'planning': return 'planning';
84
+ case 'delivered': return 'awaiting-acceptance';
85
+ case 'accepted': return 'accepted';
86
+ case 'stopped': {
87
+ const s = row.lifecycle && row.lifecycle.state;
88
+ return s === 'on-hold' || s === 'closed' || s === 'cancelled' ? s : 'unknown';
89
+ }
90
+ default: return 'unknown';
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {string[]} slugs every initiative on the board
96
+ * @param {Object<string,Object>} rows slug → derived status row (missing → Unknown)
97
+ * @param {{activeOnly?:boolean}} opts
98
+ * @returns {Array<{key,label,hint,active,slugs:string[]}>} non-empty groups only, in order
99
+ */
100
+ function group(slugs, rows, opts) {
101
+ const activeOnly = !!(opts && opts.activeOnly);
102
+ const buckets = new Map(GROUPS.map((g) => [g.key, []]));
103
+ for (const slug of slugs || []) buckets.get(keyOf(rows && rows[slug])).push(slug);
104
+ return GROUPS.filter((g) => (!activeOnly || g.active) && buckets.get(g.key).length)
105
+ .map((g) => ({ ...g, slugs: buckets.get(g.key) }));
106
+ }
107
+
108
+ return { GROUPS, BY_KEY, LEGEND_FIELDS, keyOf, group };
109
+ }));
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /* transcripts.js — the record of a conversation that belongs to an initiative.
3
+ *
4
+ * A session's terminal text used to live and die with the session: removing it deleted the only copy,
5
+ * so an initiative's discussion could not be reviewed later. A session that belongs to an initiative is
6
+ * now saved here when it ends or is removed, and listed beside the initiative's other artifacts.
7
+ *
8
+ * WHERE: one JSON file per session under `<runtime state>/transcripts/`. The runtime state directory is
9
+ * machine-local and outside every repository (lib/state-root.js). A transcript can hold anything the PM
10
+ * typed or an agent printed — including a secret — so it is deliberately NOT written into the repository
11
+ * (PM ruling 2026-10-03) and is never sent anywhere. Pure of the network (D54): fs only.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const MAX_CHARS = 400000; // the tail of a very long session; the record says when it was cut
17
+ const ID_RE = /^[A-Za-z0-9._-]+$/;
18
+
19
+ // Terminal text without its escape sequences: colours, cursor moves, the window-title and hyperlink codes.
20
+ function stripAnsi(text) {
21
+ return String(text == null ? '' : text)
22
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
23
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
24
+ .replace(/\x1b[()][A-Za-z0-9]/g, '')
25
+ .replace(/\r(?!\n)/g, '\n').replace(/\r/g, '');
26
+ }
27
+
28
+ const dirOf = (stateDir) => path.join(stateDir, 'transcripts');
29
+ const fileOf = (stateDir, id) => path.join(dirOf(stateDir), id + '.json');
30
+
31
+ /** Write (or refresh) one session's record. Returns {ok, path} or {ok:false, error}; never throws. */
32
+ function save(stateDir, rec) {
33
+ try {
34
+ const id = String(rec && rec.id || '');
35
+ if (!ID_RE.test(id)) return { ok: false, error: 'not a session id' };
36
+ if (!rec.initiative || !ID_RE.test(String(rec.initiative))) return { ok: false, error: 'the session is on no initiative — nothing to record' };
37
+ const full = stripAnsi(rec.text);
38
+ const cut = full.length > MAX_CHARS;
39
+ const out = {
40
+ id, name: String(rec.name || id).slice(0, 80), agent: rec.agent || null, ws: rec.ws || null,
41
+ initiative: String(rec.initiative), state: rec.state || 'exited',
42
+ started: rec.started || null, saved: rec.saved || new Date().toISOString(),
43
+ cut, chars: Math.min(full.length, MAX_CHARS), text: cut ? full.slice(-MAX_CHARS) : full,
44
+ };
45
+ fs.mkdirSync(dirOf(stateDir), { recursive: true });
46
+ const file = fileOf(stateDir, id);
47
+ fs.writeFileSync(file + '.tmp', JSON.stringify(out));
48
+ fs.renameSync(file + '.tmp', file);
49
+ return { ok: true, path: file };
50
+ } catch (e) { return { ok: false, error: String((e && e.message) || e).slice(0, 160) }; }
51
+ }
52
+
53
+ function read(stateDir, id) {
54
+ if (!ID_RE.test(String(id || ''))) return null;
55
+ try { return JSON.parse(fs.readFileSync(fileOf(stateDir, id), 'utf8')); } catch { return null; }
56
+ }
57
+
58
+ /** Every saved record for one initiative, newest first, without the text. */
59
+ function listFor(stateDir, initiative) {
60
+ let names = [];
61
+ try { names = fs.readdirSync(dirOf(stateDir)); } catch { return []; }
62
+ const out = [];
63
+ for (const n of names) {
64
+ if (!n.endsWith('.json')) continue;
65
+ let r = null;
66
+ try { r = JSON.parse(fs.readFileSync(path.join(dirOf(stateDir), n), 'utf8')); } catch { continue; }
67
+ if (!r || r.initiative !== initiative) continue;
68
+ out.push({ id: r.id, name: r.name, ws: r.ws, state: r.state, started: r.started, saved: r.saved, chars: r.chars, cut: !!r.cut });
69
+ }
70
+ return out.sort((a, b) => String(b.saved).localeCompare(String(a.saved)));
71
+ }
72
+
73
+ module.exports = { stripAnsi, save, read, listFor, dirOf, MAX_CHARS };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ /* typesafe-settings.js — where the TypeSafe key and the per-repository consent live (D79).
3
+ *
4
+ * The file is `<runtime state>/typesafe.json`, in the per-user runtime directory that lib/state-root.js
5
+ * keeps OUTSIDE every repository, so no commit, sync or share can carry it. It is written with mode 0600
6
+ * through a temporary file and a rename, and a symlink at the target is refused.
7
+ *
8
+ * What leaves this module: `status()` says whether a key is present and where it came from. It never
9
+ * returns the key. `getKey()` is for the server's validation path only; no route returns its value.
10
+ * `TYPESAFE_API_KEY` in the environment is read as a fallback, ONCE, at server start (`adoptEnv`), which
11
+ * then deletes the variable from the process environment. Nothing the server spawns afterwards can see it.
12
+ */
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { validKey } = require('./typesafe');
16
+
17
+ const FILE = 'typesafe.json';
18
+ const ENV_NAME = 'TYPESAFE_API_KEY';
19
+ const MAX_EXCLUDES = 100;
20
+
21
+ function file(stateDir) { return path.join(stateDir, FILE); }
22
+
23
+ function read(stateDir) {
24
+ try {
25
+ const st = fs.lstatSync(file(stateDir));
26
+ if (!st.isFile()) return { key: null, repos: {} };
27
+ const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8'));
28
+ return {
29
+ key: typeof o.key === 'string' ? o.key : null,
30
+ repos: o && typeof o.repos === 'object' && o.repos && !Array.isArray(o.repos) ? o.repos : {},
31
+ };
32
+ } catch { return { key: null, repos: {} }; }
33
+ }
34
+
35
+ function write(stateDir, data) {
36
+ const target = file(stateDir);
37
+ try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the settings file is a symlink and was not written' }; }
38
+ catch { /* not there yet */ }
39
+ const tmp = target + '.' + process.pid + '.tmp';
40
+ try {
41
+ fs.mkdirSync(stateDir, { recursive: true });
42
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
43
+ fs.chmodSync(tmp, 0o600);
44
+ fs.renameSync(tmp, target);
45
+ return { ok: true };
46
+ } catch (e) {
47
+ try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
48
+ return { ok: false, error: 'the settings could not be saved (' + String((e && e.code) || 'write failed') + ')' };
49
+ }
50
+ }
51
+
52
+ /* The environment key is TAKEN, not borrowed. `adoptEnv` moves it from the process environment into this
53
+ * module's memory and deletes the variable, so no child the server starts afterwards — a terminal, git, a
54
+ * schedule — inherits it. Called once at server start. */
55
+ let adopted = null;
56
+ function adoptEnv(env) {
57
+ if (env && validKey(env[ENV_NAME])) adopted = env[ENV_NAME];
58
+ if (env) delete env[ENV_NAME];
59
+ }
60
+ function envKey(env) { return adopted || (env && validKey(env[ENV_NAME]) ? env[ENV_NAME] : null); }
61
+
62
+ /** The key for the server's own use, or null. File first, then the environment. */
63
+ function getKey(stateDir, env = null) {
64
+ const f = read(stateDir).key;
65
+ if (validKey(f)) return f;
66
+ return envKey(env);
67
+ }
68
+
69
+ /** What the browser may know: present or not, and from where. Never the value, never its length. */
70
+ 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 };
74
+ }
75
+
76
+ function setKey(stateDir, key) {
77
+ const k = typeof key === 'string' ? key.trim() : '';
78
+ 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
+ const cur = read(stateDir);
80
+ return write(stateDir, { key: k, repos: cur.repos });
81
+ }
82
+
83
+ function clearKey(stateDir) {
84
+ const cur = read(stateDir);
85
+ return write(stateDir, { key: null, repos: cur.repos });
86
+ }
87
+
88
+ /** Consent and exclusions for one repository root. Consent defaults to off. */
89
+ function getRepo(stateDir, root) {
90
+ const r = read(stateDir).repos[root];
91
+ return {
92
+ consent: !!(r && r.consent === true),
93
+ exclude: r && Array.isArray(r.exclude) ? r.exclude.filter((x) => typeof x === 'string') : [],
94
+ };
95
+ }
96
+
97
+ function setRepo(stateDir, root, { consent, exclude }) {
98
+ const cur = read(stateDir);
99
+ const prev = getRepo(stateDir, root);
100
+ const ex = Array.isArray(exclude)
101
+ ? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
102
+ : prev.exclude;
103
+ const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex } };
104
+ return write(stateDir, { key: cur.key, repos });
105
+ }
106
+
107
+ /** Does `rel` fall under an exclusion? An entry is a path prefix, or a `*` pattern matched on the path. */
108
+ function isExcluded(exclude, rel) {
109
+ const p = String(rel).replace(/\\/g, '/');
110
+ return exclude.some((e) => {
111
+ if (e.includes('*')) {
112
+ const re = new RegExp('^' + e.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
113
+ return re.test(p);
114
+ }
115
+ return p === e || p.startsWith(e.endsWith('/') ? e : e + '/');
116
+ });
117
+ }
118
+
119
+ module.exports = { adoptEnv, status, getKey, setKey, clearKey, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /* typesafe.js — the ONE place the workspace server makes an outbound request (D79).
3
+ *
4
+ * D69, D70 and D71 say the PMOS server makes no outbound request. D79 narrows that by one named
5
+ * exception: HTTPS to `api.typesafe.ai`, from this module, and nowhere else. Three properties keep the
6
+ * exception small, and each has a test:
7
+ *
8
+ * 1. ONE HOST, FIXED IN SOURCE. The host is a constant. No argument, setting or environment variable
9
+ * names it. A response that redirects is REFUSED, not followed, because a redirect is how a fixed
10
+ * host becomes another host.
11
+ * 2. THE KEY LEAVES THIS PROCESS IN ONE HEADER. It is an argument to `ask`, never stored here, never
12
+ * placed in an error, a log line or a returned object.
13
+ * 3. NO KEY, NO REQUEST. `ask` refuses before opening a socket when the key is absent.
14
+ *
15
+ * What Jev is: a classifier. The caller sends text (`state`) and typed questions; it returns answers
16
+ * with probabilities. It reads no files and takes no action — the caller reads the files.
17
+ * Noul "high means yes"; the single `noul` number is P(yes). There is no separate confidence.
18
+ * Score levels 0..N-1; `score` is the probability-weighted mean; `confidence` is reported.
19
+ * Choice one option plus a distribution.
20
+ */
21
+ const https = require('https');
22
+ const http = require('http');
23
+
24
+ const HOST = 'api.typesafe.ai';
25
+ const PATH = '/v1/systemone';
26
+ const DEFAULT_MODEL = 'jev-latest';
27
+ const MAX_STATE_CHARS = 60000; // a conservative cap; the documentation states no limit
28
+ const MAX_RESPONSE_BYTES = 1 << 20; // 1 MiB — an answer set is small; anything larger is not one
29
+ const MAX_QUESTIONS = 16;
30
+
31
+ /* A seam for tests, gated like the sink's. It lets a suite point this module at a loopback stub; it
32
+ * is unreachable in a production process, so no setting can redirect the key to another host. */
33
+ let endpointForTests = null;
34
+ function __setEndpointForTests(e) {
35
+ const on = (v) => v === '1' || v === 'true' || v === 'yes';
36
+ if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
37
+ throw new Error('__setEndpointForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
38
+ }
39
+ endpointForTests = e || null;
40
+ }
41
+ function endpoint() {
42
+ return endpointForTests || { protocol: 'https:', hostname: HOST, port: 443 };
43
+ }
44
+
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+ const retryable = (status) => status === 429 || status === 529 || (status >= 500 && status < 600);
47
+
48
+ function validKey(key) {
49
+ return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
50
+ }
51
+
52
+ function once({ key, body, timeoutMs }) {
53
+ const ep = endpoint();
54
+ const mod = ep.protocol === 'http:' ? http : https;
55
+ const payload = Buffer.from(body, 'utf8');
56
+ return new Promise((resolve) => {
57
+ let done = false;
58
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
59
+ const req = mod.request({
60
+ protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json', 'Content-Length': payload.length,
63
+ Authorization: 'Bearer ' + key, 'User-Agent': 'pmos-workspace',
64
+ },
65
+ timeout: timeoutMs,
66
+ }, (res) => {
67
+ const status = res.statusCode || 0;
68
+ // Refused, not followed: the host is fixed in source and a redirect would change it.
69
+ if (status >= 300 && status < 400) { res.resume(); return finish({ ok: false, status, reason: 'the service sent a redirect, which is refused' }); }
70
+ const chunks = []; let size = 0;
71
+ res.on('data', (c) => {
72
+ size += c.length;
73
+ if (size > MAX_RESPONSE_BYTES) { finish({ ok: false, status, reason: 'the response was larger than expected and was dropped' }); req.destroy(); return; }
74
+ chunks.push(c);
75
+ });
76
+ res.on('end', () => {
77
+ let json = null;
78
+ try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* not JSON */ }
79
+ if (status >= 200 && status < 300 && json && typeof json.answers === 'object' && json.answers) {
80
+ return finish({ ok: true, status, model: String(json.model || ''), answers: json.answers, usage: json.usage || null });
81
+ }
82
+ const why = status === 401 ? 'the service rejected the key (401)'
83
+ : status === 422 ? 'the service rejected the request as malformed (422)'
84
+ : status === 429 ? 'the service rate-limited the request (429)'
85
+ : status === 529 ? 'the service is overloaded (529)'
86
+ : `the service answered ${status || 'with something unreadable'}`;
87
+ finish({ ok: false, status, reason: why });
88
+ });
89
+ res.on('error', () => finish({ ok: false, status, reason: 'the connection broke while reading the answer' }));
90
+ });
91
+ req.on('timeout', () => { finish({ ok: false, status: 0, reason: 'the service did not answer in time' }); req.destroy(); });
92
+ req.on('error', (e) => finish({ ok: false, status: 0, reason: 'the service could not be reached (' + String((e && e.code) || 'network error') + ')' }));
93
+ req.end(payload);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Ask Jev typed questions about `state`.
99
+ *
100
+ * @param {object} a
101
+ * @param {string} a.key the API key. Required; no key means no request.
102
+ * @param {string} a.state the text to judge. Refused above MAX_STATE_CHARS.
103
+ * @param {object} a.questions { name: { type: 'noul'|'score'|'choice', instructions, criteria? } }
104
+ * @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
105
+ * `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
106
+ */
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 });
114
+ let last = null;
115
+ for (let attempt = 0; attempt <= retries; attempt++) {
116
+ last = await once({ key, body, timeoutMs });
117
+ if (last.ok) return { ...last, requested: true };
118
+ if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
119
+ await sleep(backoffMs * Math.pow(2, attempt));
120
+ }
121
+ return { ok: false, requested: true, status: last.status, reason: last.reason };
122
+ }
123
+
124
+ module.exports = { ask, validKey, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, __setEndpointForTests };