create-pmos 0.4.0 → 0.6.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 (67) hide show
  1. package/README.md +149 -4
  2. package/bin/create-pmos.js +97 -8
  3. package/package.json +7 -3
  4. package/payload/AGENTS-HUMAN.md +6 -0
  5. package/payload/AGENTS.md +62 -3
  6. package/payload/KIT-VERSION +2 -2
  7. package/payload/README.md +16 -2
  8. package/payload/SECURITY.md +59 -0
  9. package/payload/okf/core/concepts/design-loop.md +6 -3
  10. package/payload/okf/core/concepts/frontmatter-format.md +75 -0
  11. package/payload/okf/core/concepts/index.md +3 -0
  12. package/payload/okf/core/concepts/log.md +13 -0
  13. package/payload/okf/core/templates/design-brief-template.md +1 -1
  14. package/payload/okf/core/templates/eval-rubric-template.md +12 -10
  15. package/payload/okf/core/templates/prd-template.md +6 -0
  16. package/payload/okf/core/templates/prototype-brief-template.md +45 -21
  17. package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
  18. package/payload/scripts/frontier.sh +60 -0
  19. package/payload/scripts/metrics.py +163 -102
  20. package/payload/scripts/pmos-log.sh +16 -6
  21. package/payload/skills/SKILL-FORMAT.md +72 -9
  22. package/payload/skills/discovery.skill +4 -0
  23. package/payload/skills/eval-rubric.skill +1 -1
  24. package/payload/skills/evaluator.skill +65 -20
  25. package/payload/skills/explain-run.skill +9 -6
  26. package/payload/skills/explain-surface.skill +156 -0
  27. package/payload/skills/ingest-repo.skill +4 -3
  28. package/payload/skills/pmos-router.skill +25 -3
  29. package/payload/skills/prd.skill +8 -0
  30. package/payload/skills/prototype.skill +106 -41
  31. package/payload/skills-native/copywriting/SKILL.md +57 -0
  32. package/payload/skills-native/discovery/SKILL.md +75 -0
  33. package/payload/skills-native/eval-rubric/SKILL.md +159 -0
  34. package/payload/skills-native/evaluator/SKILL.md +182 -0
  35. package/payload/skills-native/explain-run/SKILL.md +121 -0
  36. package/payload/skills-native/explain-surface/SKILL.md +158 -0
  37. package/payload/skills-native/ingest-repo/SKILL.md +120 -0
  38. package/payload/skills-native/okr/SKILL.md +70 -0
  39. package/payload/skills-native/pmos-router/SKILL.md +81 -0
  40. package/payload/skills-native/prd/SKILL.md +117 -0
  41. package/payload/skills-native/prototype/SKILL.md +147 -0
  42. package/payload/state/tickets/.gitkeep +0 -0
  43. package/payload/templates/agent-run-eval-rubric.md +10 -7
  44. package/payload/templates/ticket-template.md +20 -0
  45. package/workspace/README.md +112 -0
  46. package/workspace/REMOTE.md +131 -0
  47. package/workspace/bin/pmos.js +206 -0
  48. package/workspace/doctor.js +99 -0
  49. package/workspace/lib/console-state.js +117 -0
  50. package/workspace/lib/file-mode-parse.js +58 -0
  51. package/workspace/lib/harness-parse.js +222 -0
  52. package/workspace/lib/preview-kind.js +98 -0
  53. package/workspace/lib/rate-limit.js +26 -0
  54. package/workspace/lib/remote-access.js +235 -0
  55. package/workspace/lib/review-gate.js +39 -0
  56. package/workspace/lib/schedule-logic.js +132 -0
  57. package/workspace/lib/skill-store.js +143 -0
  58. package/workspace/lib/surface-contract.js +79 -0
  59. package/workspace/lib/transcript-read.js +247 -0
  60. package/workspace/package-lock.json +94 -0
  61. package/workspace/package.json +23 -0
  62. package/workspace/public/app.js +1710 -0
  63. package/workspace/public/index.html +175 -0
  64. package/workspace/public/styles.css +766 -0
  65. package/workspace/server.js +1753 -0
  66. package/workspace/skills/backlog-triage.skill +13 -0
  67. package/workspace/skills/weekly-report.skill +19 -0
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+ /*
3
+ * Remote (tailnet) access — the transport half of workspace-remote-tailnet.
4
+ *
5
+ * D62 is the locked security boundary: the workspace server owns real PTYs running the PM's
6
+ * Claude subscription, so it must NEVER be reachable from a public interface. This module makes
7
+ * "reach the SAME local server from my phone" possible over a PRIVATE Tailscale tailnet only:
8
+ *
9
+ * 1. Bind resolution — default is loopback ONLY (unchanged). With the opt-in remote flag on, the
10
+ * server binds loopback PLUS the machine's own tailnet IPv4 (Tailscale CGNAT 100.64.0.0/10)
11
+ * and NOTHING else. Any requested bind that is not loopback and not in 100.64.0.0/10 (e.g.
12
+ * 0.0.0.0, ::, a LAN or public IP) makes resolveBinds REFUSE — the server won't start. There
13
+ * is no code path that yields a public bind address.
14
+ * 2. Token guard — when bound beyond loopback a bearer token is REQUIRED. Every non-loopback peer
15
+ * (HTTP request or WS upgrade) must present it; loopback peers stay token-free for local dev.
16
+ * Comparison is constant-time. The token is generated if not supplied and printed ONCE at
17
+ * startup — never logged thereafter, and request headers are never logged (D08).
18
+ *
19
+ * Pure and side-effect-light so the guard logic is unit-testable without a live tailnet peer.
20
+ */
21
+ const crypto = require('crypto');
22
+ const os = require('os');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const LOOPBACK = '127.0.0.1';
26
+
27
+ // Strip an IPv4-mapped IPv6 prefix (::ffff:100.64.0.1) so one matcher handles both stacks.
28
+ function unmap(addr) { return String(addr || '').replace(/^::ffff:/i, ''); }
29
+
30
+ function parseIPv4(addr) {
31
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(unmap(addr));
32
+ if (!m) return null;
33
+ const o = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
34
+ return o.some((n) => n > 255) ? null : o;
35
+ }
36
+
37
+ // Loopback = 127.0.0.0/8 (IPv4) or ::1 (IPv6). These peers are token-exempt.
38
+ function isLoopbackAddr(addr) {
39
+ if (!addr) return false;
40
+ if (unmap(addr) === '::1') return true;
41
+ const o = parseIPv4(addr);
42
+ return !!o && o[0] === 127;
43
+ }
44
+
45
+ // The Tailscale tailnet lives in the CGNAT block 100.64.0.0/10 → first octet 100, second 64..127.
46
+ // This is the ONLY non-loopback range the server is ever permitted to bind or trust.
47
+ function isTailnetIp(addr) {
48
+ const o = parseIPv4(addr);
49
+ return !!o && o[0] === 100 && o[1] >= 64 && o[1] <= 127;
50
+ }
51
+
52
+ // Discover this machine's own tailnet IPv4. Prefers `tailscale ip -4`; falls back to scanning the
53
+ // network interfaces for a 100.64.0.0/10 address; honours an explicit PMOS_TAILNET_IP override.
54
+ // Injectable deps keep it testable and keep the spawn out of the non-remote (default/test) path.
55
+ function detectTailnetIp({ env = process.env, netInterfaces = os.networkInterfaces, exec = spawnSync } = {}) {
56
+ const override = (env.PMOS_TAILNET_IP || '').trim();
57
+ if (override) return isTailnetIp(override) ? override : null; // an explicit non-tailnet override is a mistake, not a fallback
58
+ try {
59
+ const r = exec('tailscale', ['ip', '-4'], { encoding: 'utf8', timeout: 4000 });
60
+ if (r && r.status === 0 && r.stdout) {
61
+ const ip = String(r.stdout).trim().split(/\s+/).find((x) => isTailnetIp(x));
62
+ if (ip) return ip;
63
+ }
64
+ } catch { /* tailscale not installed — fall through to the interface scan */ }
65
+ let ifaces = {};
66
+ try { ifaces = netInterfaces() || {}; } catch { ifaces = {}; }
67
+ for (const list of Object.values(ifaces)) {
68
+ for (const ni of (list || [])) {
69
+ const fam = ni && ni.family;
70
+ if ((fam === 'IPv4' || fam === 4) && isTailnetIp(ni.address)) return ni.address;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function remoteRequested(env) {
77
+ return env.PMOS_REMOTE === 'tailnet'
78
+ || env.PMOS_BIND_TAILNET === '1' || env.PMOS_BIND_TAILNET === 'true' || env.PMOS_BIND_TAILNET === 'yes';
79
+ }
80
+
81
+ // The one function that decides what the server may bind. Fail-closed: anything that is not
82
+ // loopback or a tailnet address REFUSES (the server exits), so a public bind is unreachable by
83
+ // construction. Returns {remote, binds, token, tailnetIp, generated} or {remote, refused:<msg>}.
84
+ function resolveBinds({ env = process.env, detect = detectTailnetIp } = {}) {
85
+ if (!remoteRequested(env)) {
86
+ return { remote: false, binds: [LOOPBACK], token: null, tailnetIp: null, generated: false };
87
+ }
88
+ // An explicit bind address, when given, must itself be loopback or tailnet — never public.
89
+ const requested = (env.PMOS_BIND_ADDR || '').trim();
90
+ if (requested && !isLoopbackAddr(requested) && !isTailnetIp(requested)) {
91
+ return { remote: true, refused:
92
+ `refusing to bind "${requested}": only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted — never a public/default-route interface (D62).` };
93
+ }
94
+ const ip = (requested && isTailnetIp(requested)) ? requested : detect({ env });
95
+ if (!ip) {
96
+ return { remote: true, refused:
97
+ 'PMOS_REMOTE=tailnet is set but no Tailscale tailnet address (100.64.0.0/10) was found. Start Tailscale (`tailscale up`) or set PMOS_TAILNET_IP to your tailnet IP. Refusing to bind any non-tailnet interface (D62).' };
98
+ }
99
+ if (!isTailnetIp(ip)) { // belt-and-suspenders: detect must only ever yield a tailnet IP
100
+ return { remote: true, refused:
101
+ `refusing to bind "${ip}": not within the tailnet range 100.64.0.0/10 — never a public interface (D62).` };
102
+ }
103
+ const supplied = (env.PMOS_TOKEN || '').trim();
104
+ const token = supplied || crypto.randomBytes(32).toString('base64url');
105
+ return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied };
106
+ }
107
+
108
+ // ── Token extraction ──────────────────────────────────────────────────────────────────
109
+ // HTTP: Authorization: Bearer <t>. (Node lowercases header names.)
110
+ function extractHttpToken(headers = {}) {
111
+ const h = headers.authorization || headers.Authorization || '';
112
+ const m = /^Bearer\s+(.+)$/i.exec(String(h));
113
+ return m ? m[1].trim() : null;
114
+ }
115
+ // WS upgrade: a browser cannot set headers on a WebSocket, so accept, in order, an Authorization
116
+ // header (non-browser clients), a ?token=/?access_token= query param, or a `bearer,<t>` subprotocol.
117
+ function extractWsToken(req = {}) {
118
+ const headers = req.headers || {};
119
+ const fromHeader = extractHttpToken(headers);
120
+ if (fromHeader) return fromHeader;
121
+ try {
122
+ const u = new URL(req.url || '', 'http://localhost');
123
+ const q = u.searchParams.get('token') || u.searchParams.get('access_token');
124
+ if (q) return q.trim();
125
+ } catch { /* malformed URL → no token */ }
126
+ const proto = headers['sec-websocket-protocol'];
127
+ if (proto) {
128
+ const parts = String(proto).split(',').map((s) => s.trim()).filter(Boolean);
129
+ const i = parts.findIndex((p) => /^bearer$/i.test(p));
130
+ if (i >= 0 && parts[i + 1]) return parts[i + 1];
131
+ }
132
+ return null;
133
+ }
134
+
135
+ // ── Origin / Host allowlist (D64 / pivot-workspace-first, Track A1) ─────────────────────
136
+ // The threat: the server owns real PTYs and, for loopback peers, is token-EXEMPT. Without an
137
+ // Origin/Host check, any website the PM has open in their browser can open a WebSocket to
138
+ // ws://127.0.0.1:<port> and send {t:'new'}+{t:'input'} to spawn a shell and type into it — a
139
+ // cross-origin-localhost / DNS-rebinding remote-code-execution vector. These predicates close it:
140
+ // • a same-origin browser fetch/WS (served BY this server) sends an Origin equal to the Host it
141
+ // arrived on — allowed;
142
+ // • a cross-site page sends its OWN (foreign) Origin — rejected;
143
+ // • a DNS-rebinding attack forces a foreign Host header — rejected (the Host hostname must be
144
+ // loopback/localhost or a tailnet address, never an attacker's domain);
145
+ // • a native client / curl sends no Origin — allowed here, then still gated by the token guard.
146
+ // Port-agnostic on purpose: the server may listen on any port, so an Origin is compared to the Host
147
+ // it arrived on (true same-origin) rather than to a hard-coded port. Header VALUES are never logged.
148
+ function hostnameOf(authority) {
149
+ let h = String(authority == null ? '' : authority).trim().toLowerCase();
150
+ if (!h) return '';
151
+ const scheme = h.indexOf('://');
152
+ if (scheme !== -1) h = h.slice(scheme + 3);
153
+ if (h[0] === '[') { const i = h.indexOf(']'); return i === -1 ? h.slice(1) : h.slice(1, i); }
154
+ return h.split('/')[0].split(':')[0];
155
+ }
156
+ function authorityOf(value) {
157
+ let h = String(value == null ? '' : value).trim().toLowerCase();
158
+ if (!h || h === 'null') return '';
159
+ const scheme = h.indexOf('://');
160
+ if (scheme !== -1) h = h.slice(scheme + 3);
161
+ return h.split('/')[0]; // host[:port]
162
+ }
163
+ function hostnameAllowed(authority, binds) {
164
+ const h = hostnameOf(authority);
165
+ if (!h) return false;
166
+ if (h === 'localhost') return true;
167
+ if (isLoopbackAddr(h)) return true;
168
+ for (const b of (binds || [])) if (isTailnetIp(b) && unmap(h) === unmap(b)) return true;
169
+ return false;
170
+ }
171
+ // The single decision, called from BOTH the HTTP guard and the WS-upgrade guard. Returns true when
172
+ // the request is allowed to proceed (it may still be rejected by the token guard afterwards).
173
+ function checkHostOrigin({ origin, host, binds }) {
174
+ // 1. If a Host header is present, its hostname must be loopback/localhost/tailnet (anti-rebinding).
175
+ if (host != null && host !== '') {
176
+ if (!hostnameAllowed(host, binds)) return false;
177
+ }
178
+ // 2. If an Origin header is present, it must be same-origin with the Host it arrived on, and its
179
+ // hostname must itself be in the allowlist. "null"/opaque/garbage Origins are rejected.
180
+ if (origin != null && origin !== '') {
181
+ const oa = authorityOf(origin);
182
+ if (!oa) return false;
183
+ const ha = authorityOf(host);
184
+ if (ha && oa !== ha) return false; // cross-origin → reject
185
+ if (!hostnameAllowed(oa, binds)) return false; // Host absent but Origin foreign → reject
186
+ }
187
+ return true;
188
+ }
189
+ function isAllowedOrigin(origin, { host, binds } = {}) { return checkHostOrigin({ origin, host, binds }); }
190
+
191
+ // Constant-time token comparison. Length is checked first (timingSafeEqual throws on a length
192
+ // mismatch); a differing length is already a non-match, so this leaks nothing useful.
193
+ function tokenMatches(presented, expected) {
194
+ if (!expected || !presented) return false;
195
+ const a = Buffer.from(String(presented));
196
+ const b = Buffer.from(String(expected));
197
+ if (a.length !== b.length) return false;
198
+ try { return crypto.timingSafeEqual(a, b); } catch { return false; }
199
+ }
200
+
201
+ // The single authorization decision, PEER-based (what matters is where the request came FROM, not
202
+ // which listener it hit). Loopback is always exempt; every non-loopback peer must present a valid
203
+ // bearer token. Fail-closed: with no token configured, a non-loopback peer is rejected.
204
+ function authorize({ remoteAddress, presentedToken, expectedToken }) {
205
+ if (isLoopbackAddr(remoteAddress)) return true;
206
+ return tokenMatches(presentedToken, expectedToken);
207
+ }
208
+
209
+ // The startup banner lines. This is the ONE place a token may be printed, and ONLY a freshly
210
+ // GENERATED token — a token supplied via PMOS_TOKEN is the PM's own secret and is never echoed.
211
+ // Nothing here (or anywhere in this module) logs request headers (D08).
212
+ function startupLines(bind, port) {
213
+ if (!bind || !bind.remote) {
214
+ return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
215
+ }
216
+ const lines = [
217
+ 'pmos workspace server — REMOTE (tailnet) mode',
218
+ ` local: http://${LOOPBACK}:${port}`,
219
+ ` tailnet: http://${bind.tailnetIp}:${port} (private WireGuard net, device-authenticated, never public — D62)`,
220
+ ];
221
+ if (bind.generated) {
222
+ lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
223
+ lines.push(' attach with header Authorization: Bearer <token> or query ?token=<token>');
224
+ } else {
225
+ lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
226
+ }
227
+ lines.push(' loopback requests are token-free; every tailnet request must present the token.');
228
+ return lines;
229
+ }
230
+
231
+ module.exports = {
232
+ LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
233
+ resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
234
+ checkHostOrigin, isAllowedOrigin, hostnameAllowed,
235
+ };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ /*
3
+ * CANONICAL Review-Gate pass rule — the single source of truth (D64 / pivot-workspace-first, Track D2).
4
+ *
5
+ * The rule is: an explicit verdict DECIDES (pass iff it starts with "pass"); the score is a
6
+ * fallback used ONLY when no verdict was recorded (score >= 0.85).
7
+ *
8
+ * AMENDED 2026-07-26 (blind-plant round 2 finding). The rule was previously
9
+ * `verdict ILIKE 'pass%' OR score >= 0.85`, and that disjunction was a real leniency hole: a run the
10
+ * evaluator explicitly FAILED still passed the gate if its weighted score happened to clear 0.85.
11
+ * Round 2 hit it live — specimen `be58` came back verdict=FAIL score=0.93 and the old rule counted it
12
+ * pass-side, overriding a FAIL issued under the two-axis rule ("a FAIL on either axis cannot become a
13
+ * PASS"). A gate whose plumbing can overturn its own judge is not a gate. See
14
+ * planning/evals/blind-plants/2026-07-26-round2/RESULTS.md.
15
+ *
16
+ * Note the fix is NOT a blunt OR->AND: that would wrongly fail a legitimate `PASS` verdict carrying no
17
+ * recorded score. Verdict-authoritative-with-score-fallback preserves every other case and changes
18
+ * exactly the one that was broken.
19
+ *
20
+ * It was previously duplicated verbatim across SQL views, TS, the workspace JS, and metrics.py, so a
21
+ * change in one place silently drifted the others. It now lives HERE and is mirrored in exactly one
22
+ * other place — kit/scripts/metrics.py's `review_gate_pass()` — with a comment pointing back here.
23
+ * kit/fixtures/test_parsers.py runs BOTH implementations against kit/fixtures/review-gate-pass-rule.json
24
+ * and fails CI if they ever disagree, so the mirror can never drift unnoticed.
25
+ */
26
+ const PASS_VERDICT_PREFIX = 'pass'; // ILIKE 'pass%' → lowercased startsWith('pass')
27
+ const SCORE_THRESHOLD = 0.85;
28
+
29
+ // A recorded verdict is AUTHORITATIVE: the run passes iff that verdict starts with "pass"
30
+ // (PASS / PASS-WITH-FINDINGS / …). An explicit FAIL is never overridden by a high score.
31
+ // Only when NO verdict was recorded (null / "" / whitespace) does the numeric score decide.
32
+ function isReviewPass(verdict, score) {
33
+ const v = String(verdict == null ? '' : verdict).trim().toLowerCase();
34
+ if (v) return v.startsWith(PASS_VERDICT_PREFIX);
35
+ const s = Number(score);
36
+ return Number.isFinite(s) && s >= SCORE_THRESHOLD;
37
+ }
38
+
39
+ module.exports = { isReviewPass, PASS_VERDICT_PREFIX, SCORE_THRESHOLD };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+ /* schedule-logic.js — the pure cadence math for scheduled tasks.
3
+ *
4
+ * Harvested from pm-os `do-worker/src/schedule-logic.mjs`: no I/O, no globals, importable by the
5
+ * server tick AND (conceptually) the GUI's "next run" preview — so what the PM is shown is exactly
6
+ * what will fire. Window-based due-ness, NO catch-up (a missed slot is skipped, per the PRD): a
7
+ * schedule is due iff a scheduled slot falls in the interval (lastRun, now].
8
+ *
9
+ * A schedule: { cadence:'daily'|'weekly'|'bi-weekly'|'monthly', day:'Mon'..'Sun' (weekly/bi-weekly),
10
+ * dayOfMonth:1..28 (monthly), time:'HH:MM', enabled, lastRun:ISO|null }. Times are host-local (v1).
11
+ */
12
+
13
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // JS getDay() index
14
+ const CADENCES = ['daily', 'weekly', 'bi-weekly', 'monthly'];
15
+
16
+ function parseTime(t) {
17
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '09:00'));
18
+ if (!m) return { h: 9, m: 0 };
19
+ return { h: Math.min(23, +m[1]), m: Math.min(59, +m[2]) };
20
+ }
21
+ function atTime(d, time) {
22
+ const { h, m } = parseTime(time);
23
+ const x = new Date(d);
24
+ x.setHours(h, m, 0, 0);
25
+ return x;
26
+ }
27
+ function dayIndex(day) { const i = DAYS.indexOf(String(day)); return i < 0 ? 1 : i; } // default Mon
28
+
29
+ // The most recent scheduled slot at or before `now` (Date), or null if none exists yet.
30
+ function lastSlot(schedule, now) {
31
+ const cad = schedule.cadence;
32
+ if (cad === 'daily') {
33
+ const today = atTime(now, schedule.time);
34
+ return today <= now ? today : addDays(today, -1);
35
+ }
36
+ if (cad === 'weekly' || cad === 'bi-weekly') {
37
+ // walk back from today to the most recent matching weekday at time
38
+ const want = dayIndex(schedule.day);
39
+ let d = atTime(now, schedule.time);
40
+ for (let i = 0; i < 8; i++) {
41
+ if (d.getDay() === want && d <= now) return d;
42
+ d = addDays(d, -1);
43
+ }
44
+ return null;
45
+ }
46
+ if (cad === 'monthly') {
47
+ const dom = Math.min(28, Math.max(1, +schedule.dayOfMonth || 1));
48
+ let d = atTime(now, schedule.time); d.setDate(dom);
49
+ if (d > now) { d = new Date(d); d.setMonth(d.getMonth() - 1); d.setDate(dom); }
50
+ return d;
51
+ }
52
+ return null;
53
+ }
54
+ // The next scheduled slot strictly after `now`.
55
+ function nextSlot(schedule, now) {
56
+ const cad = schedule.cadence;
57
+ if (cad === 'daily') {
58
+ const today = atTime(now, schedule.time);
59
+ return today > now ? today : addDays(today, 1);
60
+ }
61
+ if (cad === 'weekly' || cad === 'bi-weekly') {
62
+ const want = dayIndex(schedule.day);
63
+ let d = atTime(now, schedule.time);
64
+ for (let i = 0; i < 8; i++) {
65
+ if (d.getDay() === want && d > now) return d;
66
+ d = addDays(d, 1);
67
+ }
68
+ return null;
69
+ }
70
+ if (cad === 'monthly') {
71
+ const dom = Math.min(28, Math.max(1, +schedule.dayOfMonth || 1));
72
+ let d = atTime(now, schedule.time); d.setDate(dom);
73
+ if (d <= now) { d = new Date(d); d.setMonth(d.getMonth() + 1); d.setDate(dom); }
74
+ return d;
75
+ }
76
+ return null;
77
+ }
78
+ function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
79
+
80
+ // Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
81
+ // require ≥ 13 days since lastRun (weekly slots, fired every other week).
82
+ function isDue(schedule, now = new Date()) {
83
+ if (!schedule || !schedule.enabled) return false;
84
+ const slot = lastSlot(schedule, now);
85
+ if (!slot) return false;
86
+ const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
87
+ if (last && last >= slot) return false;
88
+ if (schedule.cadence === 'bi-weekly' && last) {
89
+ if ((now - last) / 86400000 < 13) return false;
90
+ }
91
+ return true;
92
+ }
93
+ // The next fire time as an ISO string (or null).
94
+ function nextRun(schedule, now = new Date()) {
95
+ const s = nextSlot(schedule, now);
96
+ return s ? s.toISOString() : null;
97
+ }
98
+ // A short human phrase for the pane, e.g. "every Fri at 09:00".
99
+ function humanCadence(schedule) {
100
+ const t = schedule.time || '09:00';
101
+ switch (schedule.cadence) {
102
+ case 'daily': return `every day at ${t}`;
103
+ case 'weekly': return `every ${schedule.day || 'Mon'} at ${t}`;
104
+ case 'bi-weekly': return `every other ${schedule.day || 'Mon'} at ${t}`;
105
+ case 'monthly': return `monthly on day ${schedule.dayOfMonth || 1} at ${t}`;
106
+ default: return schedule.cadence || '';
107
+ }
108
+ }
109
+ // Validate/normalize a schedule submitted by the client. Returns { ok, schedule } or { ok:false, error }.
110
+ function normalizeSchedule(input) {
111
+ const s = input || {};
112
+ if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
113
+ if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
114
+ if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
115
+ return {
116
+ ok: true,
117
+ schedule: {
118
+ name: String(s.name).trim().slice(0, 80),
119
+ cadence: s.cadence,
120
+ day: s.day || 'Mon',
121
+ dayOfMonth: Math.min(28, Math.max(1, +s.dayOfMonth || 1)),
122
+ time: parseTimeStr(s.time),
123
+ tz: s.tz || 'local',
124
+ task: s.task || s.skill,
125
+ skill: String(s.skill).trim(),
126
+ enabled: s.enabled !== false,
127
+ },
128
+ };
129
+ }
130
+ function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
131
+
132
+ module.exports = { CADENCES, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+ /* skill-store.js — file-driven skills. ONE user-facing concept.
3
+ *
4
+ * A "skill" is a named, repeatable task with a consistent-output contract. HOW it runs — the server
5
+ * derives it instantly, or a `claude` PTY session reasons through it — is an INTERNAL field (`run:`)
6
+ * the picker never exposes. That collapses the old deterministic-vs-agent "kind" leak the PM (rightly)
7
+ * found confusing (workspace-skill-authoring): from the user's view there is just a skill.
8
+ *
9
+ * A skill file (`*.skill`) is frontmatter + a body procedure:
10
+ * - frontmatter: name, description, and an `output` list = the section keys it ALWAYS produces
11
+ * (the consistency contract, mirroring the surface-contract shape check).
12
+ * - body: the procedure. For an agent skill this is the prompt fed to the session; for a built-in
13
+ * deterministic skill the body is documentation (the server derives the output from `surface`).
14
+ *
15
+ * Built-in skills ship committed under workspace/skills/; user-authored skills are scaffolded by the
16
+ * New Skill flow into a writable dir (state/skills/, gitignored like other state). Pure of the network
17
+ * (D54) — fs only, no deps, unit-testable directly.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ // A skill name is a short slug — it is both the id and the filename stem, so it must be path-safe.
23
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
24
+ const MAX_OUTPUT = 20;
25
+
26
+ function slugify(s) {
27
+ return String(s || '').toLowerCase().trim()
28
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
29
+ }
30
+ function titleCase(s) {
31
+ return String(s || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim()
32
+ .replace(/\b\w/g, (c) => c.toUpperCase());
33
+ }
34
+ function stripQuotes(v) { return String(v).replace(/^["']|["']$/g, ''); }
35
+
36
+ // A permissive mini-parser: an optional leading `---` frontmatter block (scalar `key: value` and
37
+ // one-level `- ` lists), then the body. No YAML dependency — the format is deliberately tiny and
38
+ // self-explanatory (the ACI design principle, D19). Unknown keys are tolerated.
39
+ function parseSkill(text) {
40
+ const src = String(text || '');
41
+ const fm = {}; const lists = {};
42
+ let body = src.trim();
43
+ const lines = src.split('\n');
44
+ if (lines[0] && lines[0].trim() === '---') {
45
+ let curList = null; let closed = false; let bodyStart = 1;
46
+ for (let i = 1; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (line.trim() === '---') { bodyStart = i + 1; closed = true; break; }
49
+ const item = line.match(/^\s*-\s+(.*)$/);
50
+ if (item && curList) { lists[curList].push(item[1].trim()); continue; }
51
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
52
+ if (kv) {
53
+ const key = kv[1]; const val = kv[2].trim();
54
+ if (val === '') { curList = key; lists[key] = lists[key] || []; }
55
+ else { fm[key] = stripQuotes(val); curList = null; }
56
+ }
57
+ }
58
+ if (closed) body = lines.slice(bodyStart).join('\n').trim();
59
+ }
60
+ const name = fm.name || '';
61
+ // `run` is the ONLY internal signal: deterministic (server fast-path) vs agent (a PTY session).
62
+ const run = /^det/i.test(fm.run || '') ? 'deterministic' : 'agent';
63
+ return {
64
+ name,
65
+ label: fm.label || titleCase(name),
66
+ description: fm.description || '',
67
+ run,
68
+ surface: fm.surface || null,
69
+ agent: fm.agent || 'claude',
70
+ output: (lists.output || []).filter(Boolean).slice(0, MAX_OUTPUT),
71
+ body,
72
+ };
73
+ }
74
+
75
+ function loadDir(dir, source) {
76
+ const out = [];
77
+ let ents = [];
78
+ try { ents = fs.readdirSync(dir); } catch { return out; }
79
+ for (const f of ents.sort()) {
80
+ if (!f.endsWith('.skill')) continue;
81
+ let text = '';
82
+ try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
83
+ const s = parseSkill(text);
84
+ const id = s.name || f.replace(/\.skill$/, '');
85
+ if (!NAME_RE.test(id)) continue; // an unparseable/hostile filename never becomes a skill
86
+ out.push({ ...s, name: id, id, skill: id, source, file: path.join(dir, f) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Merge a list of {dir, source} — LATER dirs override earlier by id. Callers pass user dirs first and
92
+ // built-ins last, so a built-in's load-bearing fast-path can never be shadowed by a user file.
93
+ function loadSkills(dirs) {
94
+ const byId = new Map();
95
+ for (const { dir, source } of dirs) for (const s of loadDir(dir, source)) byId.set(s.id, s);
96
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
97
+ }
98
+
99
+ // Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
100
+ // or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
101
+ // claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
102
+ function scaffoldSkill({ name, description, output, body } = {}) {
103
+ const slug = slugify(name);
104
+ if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
105
+ const label = titleCase(name) || slug;
106
+ const desc = String(description || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 240);
107
+ let out = Array.isArray(output) ? output : String(output || '').split(/[\n,]/);
108
+ out = out.map((x) => String(x).replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, MAX_OUTPUT);
109
+ if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
110
+ const proc = String(body || '').trim() || defaultBody(label, desc, out);
111
+ const content = [
112
+ '---',
113
+ `name: ${slug}`,
114
+ `label: ${JSON.stringify(label)}`,
115
+ `description: ${JSON.stringify(desc)}`,
116
+ 'run: agent',
117
+ 'agent: claude',
118
+ 'output:',
119
+ ...out.map((o) => ` - ${o}`),
120
+ '---',
121
+ '',
122
+ proc,
123
+ '',
124
+ ].join('\n');
125
+ return { ok: true, slug, label, description: desc, output: out, content };
126
+ }
127
+
128
+ function defaultBody(label, desc, out) {
129
+ return [
130
+ `You are running the "${label}" skill in this PMOS workspace (local, file mode).`,
131
+ '',
132
+ `Goal: ${desc || '(describe what this skill should do)'}`,
133
+ '',
134
+ 'Produce a deliverable with EXACTLY these sections — the consistency contract, the same shape',
135
+ 'every run:',
136
+ ...out.map((o) => ` - ${o}`),
137
+ '',
138
+ 'Ground everything in this repository\'s own local files and tools only (no network). When you are',
139
+ 'done, write the deliverable and file a short note to needs-you so the PM sees the result.',
140
+ ].join('\n');
141
+ }
142
+
143
+ module.exports = { NAME_RE, slugify, titleCase, parseSkill, loadDir, loadSkills, scaffoldSkill };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ /* surface-contract.js — the deliverable contract for on-demand workspace surfaces.
3
+ *
4
+ * Harvested from pm-os `do-worker/src/deliverable-contract.mjs` (the pm-os intake, Cluster J / J1):
5
+ * each surface TYPE declares the shape it must carry, and a deterministic check validates a surface
6
+ * BEFORE it renders — so "reliable when the PM needs it" is ENFORCED, not hoped. Pure: no I/O, no
7
+ * DOM, no deps, importable by the server and unit-tested directly.
8
+ *
9
+ * A required item FIELD may be null — that means "not tracked in state yet" (RICE/effort/…), and the
10
+ * renderer shows "— not tracked" for it. The contract only insists the KEY exists, so the surface can
11
+ * never silently drop a field; it can only honestly say it doesn't have the value. The `min` on the
12
+ * primary collection is the "can't-produce" floor: a surface with nothing to show fails the contract
13
+ * and the client renders the explicit can't-produce state instead of a blank or partial view.
14
+ */
15
+
16
+ const CONTRACTS = Object.freeze({
17
+ initiatives: {
18
+ label: 'Initiative list',
19
+ top: ['initiatives', 'pulse', 'needs'],
20
+ item: { key: 'initiatives', min: 1, fields: ['id', 'status', 'phase'] },
21
+ },
22
+ roadmap: {
23
+ label: 'Roadmap',
24
+ top: ['groups'],
25
+ item: { key: 'groups', min: 1, fields: ['label', 'items'] },
26
+ },
27
+ okr: {
28
+ label: 'OKR + levers',
29
+ top: ['objectives'],
30
+ item: { key: 'objectives', min: 1, fields: ['id', 'krs'] },
31
+ },
32
+ report: {
33
+ label: 'Report',
34
+ top: ['cadence', 'sections'],
35
+ item: { key: 'sections', min: 3, fields: ['heading'] },
36
+ },
37
+ });
38
+
39
+ function checkSurface(type, data) {
40
+ const c = CONTRACTS[type];
41
+ if (!c) {
42
+ return { pass: false, missing: ['contract'],
43
+ checks: [{ name: 'known-type', pass: false, reason: `no contract for surface "${type}"` }] };
44
+ }
45
+ data = data || {};
46
+ const checks = [];
47
+ const missing = [];
48
+
49
+ // 1) required top-level keys are present
50
+ for (const k of c.top) {
51
+ const present = data[k] !== undefined && data[k] !== null;
52
+ checks.push({ name: `has:${k}`, pass: present, reason: present ? 'present' : 'absent' });
53
+ if (!present) missing.push(k);
54
+ }
55
+
56
+ // 2) the primary collection carries at least `min` items (the can't-produce floor)
57
+ const it = c.item;
58
+ const arr = Array.isArray(data[it.key]) ? data[it.key] : [];
59
+ const enough = arr.length >= it.min;
60
+ checks.push({ name: `min:${it.key}`, pass: enough, reason: `${arr.length} / ${it.min} required` });
61
+ if (!enough) missing.push(it.key);
62
+
63
+ // 3) every item carries its required field KEYS (a null value = "not tracked", still valid)
64
+ let fieldOk = true;
65
+ let offendingField = null;
66
+ for (const item of arr) {
67
+ for (const f of it.fields) {
68
+ if (!item || !Object.prototype.hasOwnProperty.call(item, f)) { fieldOk = false; offendingField = f; break; }
69
+ }
70
+ if (!fieldOk) break;
71
+ }
72
+ checks.push({ name: 'item-fields', pass: fieldOk,
73
+ reason: fieldOk ? `every item carries ${it.fields.join(', ')}` : `an item is missing "${offendingField}"` });
74
+ if (!fieldOk) missing.push('item-fields');
75
+
76
+ return { pass: checks.every((x) => x.pass), missing, checks };
77
+ }
78
+
79
+ module.exports = { CONTRACTS, checkSurface };