create-pmos 0.38.8 → 0.40.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 (49) 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 +37 -1
  7. package/workspace/bin/pmos.js +8 -1
  8. package/workspace/guide/index.md +40 -0
  9. package/workspace/guide/initiatives-and-next-steps.md +72 -0
  10. package/workspace/guide/layout.md +73 -0
  11. package/workspace/guide/proactive-engine.md +75 -0
  12. package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
  13. package/workspace/guide/statuses.md +53 -0
  14. package/workspace/guide/validate-with-typesafe.md +80 -0
  15. package/workspace/lib/brief.js +113 -0
  16. package/workspace/lib/claims.js +605 -0
  17. package/workspace/lib/doc-profiles.js +74 -0
  18. package/workspace/lib/initiative-files.js +37 -6
  19. package/workspace/lib/judgment.js +65 -0
  20. package/workspace/lib/mobile-nav.js +1 -1
  21. package/workspace/lib/next-step-actions.js +139 -0
  22. package/workspace/lib/next-steps.js +27 -7
  23. package/workspace/lib/preview-kind.js +17 -2
  24. package/workspace/lib/proactive-cli.js +137 -0
  25. package/workspace/lib/proactive.js +208 -0
  26. package/workspace/lib/repo-watch.js +120 -0
  27. package/workspace/lib/schedule-cli.js +5 -2
  28. package/workspace/lib/secret-scan.js +28 -0
  29. package/workspace/lib/session-budget.js +35 -0
  30. package/workspace/lib/status-groups.js +53 -10
  31. package/workspace/lib/typesafe-settings.js +122 -0
  32. package/workspace/lib/typesafe.js +124 -0
  33. package/workspace/lib/validate.js +304 -0
  34. package/workspace/package-lock.json +1 -1
  35. package/workspace/package.json +3 -3
  36. package/workspace/public/pm/app.js +53 -18
  37. package/workspace/public/pm/data.js +18 -3
  38. package/workspace/public/pm/mobile.js +1 -1
  39. package/workspace/public/pm/modes-live.js +373 -0
  40. package/workspace/public/pm/modes.js +26 -0
  41. package/workspace/public/pm/next-step-ui.js +185 -0
  42. package/workspace/public/pm/panel-detail.js +47 -11
  43. package/workspace/public/pm/panel-initiatives.js +26 -4
  44. package/workspace/public/pm/panel-repos.js +2 -2
  45. package/workspace/public/pm/state.js +3 -3
  46. package/workspace/public/pm/status-banner.js +87 -0
  47. package/workspace/public/pm/tab-doc.js +66 -8
  48. package/workspace/server.js +562 -18
  49. package/workspace/skills/morning-brief.skill +43 -0
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+ /* proactive.js — the proactive engine's pure core (initiative `proactive-engine`, slice D1).
3
+ *
4
+ * The workspace used to wait to be asked. This module notices what needs the PM, from facts the repositories already
5
+ * hold, ranks it, and validates the feedback the PM gives about it. It is pure: no I/O, no clock, no model, no network.
6
+ * The server gathers the facts (ledgers and git) and persists the records; this decides.
7
+ *
8
+ * detect(ctx) the candidates that are true NOW — a condition that has ended is simply not returned
9
+ * rank(cands) the fixed, stated order
10
+ * newSignals(...) which candidates have not been appended to signals.jsonl inside the dedupe window
11
+ * dismissedIds(...) which candidates the PM dismissed within the last seven days
12
+ * validateFeedback(body) the four verdicts, the implicit events, an optional one-line note
13
+ * normalizeSettings(raw) off, per-trigger off, quiet hours, the stall threshold, pause learning
14
+ * inQuietHours(now, quiet) whether automatic scans are held
15
+ *
16
+ * A candidate is never a status. Nothing here changes an initiative, a lifecycle line or an acceptance.
17
+ */
18
+ const crypto = require('crypto');
19
+
20
+ const TRIGGERS = ['needs', 'returned', 'review', 'schedule', 'stalled'];
21
+ const VERDICTS = ['useful', 'not-useful', 'wrong-priority', 'missed'];
22
+ const EVENTS = ['opened', 'acted', 'dismissed', 'ignored'];
23
+ const DAY = 86400000;
24
+ const DEDUPE_MS = DAY;
25
+ const DISMISS_MS = 7 * DAY;
26
+
27
+ // The fixed ranking, stated to the PM: what blocks on them first, then an unreviewed session, then a failed schedule,
28
+ // then a stalled initiative. Within a kind, older first (age in days, capped at 30).
29
+ const BASE = { needs: 100, returned: 100, review: 80, schedule: 60, stalled: 40 };
30
+ const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stalled: 'agent' };
31
+
32
+ const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
33
+ // Mail is off by default: a brief reads titles and metadata of the other sources, and the PM turns mail on.
34
+ const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, jira: true, confluence: true });
35
+ const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
36
+
37
+ const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
38
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
39
+ const days = (ms) => Math.floor(ms / DAY);
40
+
41
+ function cand(kind, key, p) {
42
+ return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null,
43
+ title: clip(p.title, 120), why: clip(p.why, 200), evidence: clip(p.evidence, 240), confidence: p.confidence, since: p.since == null ? null : p.since,
44
+ open: p.open || null };
45
+ }
46
+
47
+ /**
48
+ * @param {{now:number, settings:object, repos:Array, nextStepRuns:Array}} ctx
49
+ * repos[]: {name, ws, error?, initiatives:[{id,title,active,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
50
+ * returned:[{initiative_id,note,at}], schedules:[{id,name,lastStatus,lastRun}]}
51
+ * nextStepRuns[]: {session_id, initiative_id, ws, key, ended, reviewed, at}
52
+ * @returns {{candidates:Array, unreadable:Array<{repo:string,error:string}>}}
53
+ */
54
+ function detect(ctx) {
55
+ const now = ctx.now; const st = ctx.settings || DEFAULTS; const on = (t) => st.triggers ? st.triggers[t] !== false : true;
56
+ const out = []; const unreadable = [];
57
+ for (const r of ctx.repos || []) {
58
+ if (r.error) { unreadable.push({ repo: r.name, error: clip(r.error, 200) }); continue; }
59
+ const byId = new Map((r.initiatives || []).map((i) => [i.id, i]));
60
+ if (on('needs')) {
61
+ for (const n of r.needs || []) {
62
+ const ini = byId.get(n.initiative_id);
63
+ out.push(cand('needs', `needs:${r.ws}:${n.run_id || n.initiative_id + ':' + clip(n.question, 60)}`, {
64
+ initiative_id: n.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
65
+ why: 'An open item is waiting on you.', evidence: n.question, confidence: 0.95, since: null,
66
+ open: n.initiative_id ? { type: 'initiative', initiative_id: n.initiative_id, ws: r.ws } : null }));
67
+ }
68
+ }
69
+ if (on('returned')) {
70
+ for (const x of r.returned || []) {
71
+ const ini = byId.get(x.initiative_id);
72
+ if (ini && !ini.active) continue;
73
+ out.push(cand('returned', `returned:${r.ws}:${x.initiative_id}:${x.at || ''}`, {
74
+ initiative_id: x.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : x.initiative_id,
75
+ why: 'You returned this at the Acceptance Gate and it has not been accepted since.', evidence: x.note || 'returned', confidence: 0.95, since: x.at ? Date.parse(x.at) || null : null,
76
+ open: { type: 'initiative', initiative_id: x.initiative_id, ws: r.ws } }));
77
+ }
78
+ }
79
+ if (on('schedule')) {
80
+ for (const s of r.schedules || []) {
81
+ if (s.lastStatus !== 'fail') continue;
82
+ out.push(cand('schedule', `schedule:${s.id}:${s.lastRun || ''}`, {
83
+ ws: r.ws, repo: r.name, title: s.name || s.id, why: 'A scheduled task failed on its last run.',
84
+ evidence: `${s.name || s.id} — last run ${s.lastRun ? new Date(s.lastRun).toISOString().slice(0, 16).replace('T', ' ') : 'unknown'} reported fail`,
85
+ confidence: 0.9, since: s.lastRun ? Date.parse(s.lastRun) || null : null, open: { type: 'schedule', id: s.id, ws: r.ws } }));
86
+ }
87
+ }
88
+ if (on('stalled')) {
89
+ const limit = (st.stalledDays || DEFAULTS.stalledDays) * DAY;
90
+ for (const i of r.initiatives || []) {
91
+ if (!i.active || !i.hasRuns || i.lastRun == null) continue;
92
+ const idle = now - i.lastRun;
93
+ if (idle < limit) continue;
94
+ const d = days(idle);
95
+ out.push(cand('stalled', `stalled:${r.ws}:${i.id}`, {
96
+ initiative_id: i.id, ws: r.ws, repo: r.name, title: i.title || i.id,
97
+ why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${st.stalledDays || DEFAULTS.stalledDays} days`,
98
+ confidence: Math.min(0.85, +(0.5 + 0.05 * (d - (st.stalledDays || DEFAULTS.stalledDays))).toFixed(2)), since: i.lastRun,
99
+ open: { type: 'initiative', initiative_id: i.id, ws: r.ws } }));
100
+ }
101
+ }
102
+ }
103
+ if (on('review')) {
104
+ for (const x of ctx.nextStepRuns || []) {
105
+ if (!x.ended || x.reviewed) continue;
106
+ out.push(cand('review', `review:${x.session_id}`, {
107
+ initiative_id: x.initiative_id, ws: x.ws, repo: null, title: `${x.key || 'a step'} · ${x.initiative_id}`,
108
+ why: 'A session started from a next step has ended and has no verdict from you.', evidence: `session ${x.session_id}`, confidence: 0.9, since: x.at ? Date.parse(x.at) || null : null,
109
+ open: { type: 'review', initiative_id: x.initiative_id, ws: x.ws, session_id: x.session_id } }));
110
+ }
111
+ }
112
+ return { candidates: out, unreadable };
113
+ }
114
+
115
+ function rank(cands, now) {
116
+ const score = (c) => BASE[c.kind] + (c.since != null && now != null ? Math.min(30, Math.max(0, days(now - c.since))) : 0);
117
+ return cands.map((c) => ({ ...c, rank: score(c) })).sort((a, b) => b.rank - a.rank || (a.id < b.id ? -1 : 1))
118
+ .map((c, i) => ({ ...c, n: i + 1 }));
119
+ }
120
+
121
+ /** Candidates whose key was not already appended within the dedupe window. `recent` = signals.jsonl rows `{key, at}`. */
122
+ function newSignals(cands, recent, now) {
123
+ const seen = new Set();
124
+ for (const r of recent || []) { const t = Date.parse(r && r.at); if (r && r.key && Number.isFinite(t) && now - t < DEDUPE_MS) seen.add(r.key); }
125
+ return cands.filter((c) => !seen.has(c.key));
126
+ }
127
+
128
+ /** The ids the PM dismissed inside the dismissal window. `feedback` = proactive-feedback.jsonl rows. */
129
+ function dismissedIds(feedback, now) {
130
+ const out = new Set();
131
+ for (const f of feedback || []) {
132
+ if (!f || f.event !== 'dismissed' || !f.candidate_id) continue;
133
+ const t = Date.parse(f.at);
134
+ if (Number.isFinite(t) && now - t < DISMISS_MS) out.add(f.candidate_id);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ const ID_RE = /^[0-9a-f]{12}$/;
140
+ /** @returns {{ok:true, record:object}|{ok:false, error:string}} — a verdict, or an implicit event, never both. */
141
+ function validateFeedback(body) {
142
+ const b = body && typeof body === 'object' ? body : {};
143
+ const note = clip(b.note, 300);
144
+ const hasVerdict = b.verdict !== undefined && b.verdict !== null && b.verdict !== '';
145
+ const hasEvent = b.event !== undefined && b.event !== null && b.event !== '';
146
+ if (hasVerdict === hasEvent) return { ok: false, error: `give a verdict (${VERDICTS.join(', ')}) or an event (${EVENTS.join(', ')}), not both and not neither` };
147
+ if (hasVerdict) {
148
+ if (!VERDICTS.includes(b.verdict)) return { ok: false, error: `verdict must be one of ${VERDICTS.join(', ')}` };
149
+ if (b.verdict === 'missed') {
150
+ if (!note) return { ok: false, error: 'say what was missed: a "missed" verdict needs a note' };
151
+ if (b.candidate_id !== undefined && b.candidate_id !== null && b.candidate_id !== '' && !ID_RE.test(String(b.candidate_id))) return { ok: false, error: 'candidate_id is not a candidate id' };
152
+ return { ok: true, record: { kind: 'verdict', candidate_id: b.candidate_id || null, verdict: 'missed', note } };
153
+ }
154
+ } else if (!EVENTS.includes(b.event)) return { ok: false, error: `event must be one of ${EVENTS.join(', ')}` };
155
+ if (!ID_RE.test(String(b.candidate_id || ''))) return { ok: false, error: 'candidate_id is required and must be a candidate id' };
156
+ return hasVerdict ? { ok: true, record: { kind: 'verdict', candidate_id: b.candidate_id, verdict: b.verdict, note } }
157
+ : { ok: true, record: { kind: 'event', candidate_id: b.candidate_id, event: b.event, note } };
158
+ }
159
+
160
+ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
161
+ /** @returns {{ok:true, settings:object}|{ok:false, error:string}} — `raw` is merged over the defaults; nothing is partly applied. */
162
+ function normalizeSettings(raw) {
163
+ const r = raw && typeof raw === 'object' ? raw : {};
164
+ const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
165
+ if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
166
+ if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
167
+ if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
168
+ if (r.briefSources !== undefined) {
169
+ if (!r.briefSources || typeof r.briefSources !== 'object' || Array.isArray(r.briefSources)) return { ok: false, error: `briefSources must be an object of ${BRIEF_SOURCES.join(', ')} → true or false` };
170
+ for (const [k, v] of Object.entries(r.briefSources)) {
171
+ if (!BRIEF_SOURCES.includes(k)) return { ok: false, error: `unknown brief source "${k}" — one of ${BRIEF_SOURCES.join(', ')}` };
172
+ if (typeof v !== 'boolean') return { ok: false, error: `brief source "${k}" must be true or false` };
173
+ s.briefSources[k] = v;
174
+ }
175
+ }
176
+ if (r.pauseLearning !== undefined) { if (typeof r.pauseLearning !== 'boolean') return { ok: false, error: 'pauseLearning must be true or false' }; s.pauseLearning = r.pauseLearning; }
177
+ if (r.triggers !== undefined) {
178
+ if (!r.triggers || typeof r.triggers !== 'object' || Array.isArray(r.triggers)) return { ok: false, error: `triggers must be an object of ${TRIGGERS.join(', ')} → true or false` };
179
+ for (const [k, v] of Object.entries(r.triggers)) {
180
+ if (!TRIGGERS.includes(k)) return { ok: false, error: `unknown trigger "${k}" — one of ${TRIGGERS.join(', ')}` };
181
+ if (typeof v !== 'boolean') return { ok: false, error: `trigger "${k}" must be true or false` };
182
+ s.triggers[k] = v;
183
+ }
184
+ }
185
+ if (r.stalledDays !== undefined) {
186
+ const n = Number(r.stalledDays);
187
+ if (!Number.isInteger(n) || n < 1 || n > 90) return { ok: false, error: 'stalledDays must be a whole number from 1 to 90' };
188
+ s.stalledDays = n;
189
+ }
190
+ if (r.quiet !== undefined) {
191
+ if (r.quiet === null) s.quiet = null;
192
+ else if (!r.quiet || typeof r.quiet !== 'object' || !HHMM.test(String(r.quiet.start)) || !HHMM.test(String(r.quiet.end)) || r.quiet.start === r.quiet.end) {
193
+ return { ok: false, error: 'quiet must be null, or {start, end} as HH:MM (24-hour, local), different from each other' };
194
+ } else s.quiet = { start: r.quiet.start, end: r.quiet.end };
195
+ }
196
+ return { ok: true, settings: s };
197
+ }
198
+
199
+ /** Local time of day against a quiet window; a window that wraps midnight (22:00–07:00) is handled. */
200
+ function inQuietHours(nowMs, quiet) {
201
+ if (!quiet) return false;
202
+ const d = new Date(nowMs); const m = d.getHours() * 60 + d.getMinutes();
203
+ const p = (s) => { const [h, mm] = s.split(':').map(Number); return h * 60 + mm; };
204
+ const a = p(quiet.start); const b = p(quiet.end);
205
+ return a < b ? m >= a && m < b : m >= a || m < b;
206
+ }
207
+
208
+ module.exports = { BRIEF_SOURCES, TRIGGERS, VERDICTS, EVENTS, DEFAULTS, DEDUPE_MS, DISMISS_MS, BASE, detect, rank, newSignals, dismissedIds, validateFeedback, normalizeSettings, inQuietHours };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+ /* repo-watch.js — notice that files in a repository changed, and ask for one check (claims-ledger C8).
3
+ *
4
+ * WHY A POLL AND NOT fs.watch. A recursive watch needs Node 20 on Linux and can hit the inotify limit on a big tree,
5
+ * and it still would not see a `git checkout` or a `git pull`. The signature below changes for all of them: an edit in
6
+ * the editor, a write by an agent in a terminal, a checkout, a pull.
7
+ *
8
+ * THE SIGNATURE. `git rev-parse HEAD`, plus the porcelain status, plus the mtime and size of each dirty file — a file
9
+ * edited twice shows the same status line twice, so the status alone would miss the second edit. A folder that is not
10
+ * a git checkout uses the mtime and size of its in-scope files.
11
+ *
12
+ * DEBOUNCE. An agent writes many files in a burst. A change starts the clock; the check runs once the signature has
13
+ * stayed the same for `stableMs`. A burst that keeps changing keeps postponing, and ends as ONE run.
14
+ *
15
+ * WHAT IT NEVER DOES. It runs async git only, with read-only verbs. It opens no network connection and calls no git
16
+ * network verb. It never runs a second check for a repository while one is in flight. It checks only repositories
17
+ * `enabled(root)` says to, and the PM can switch that off per repository.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const { execFile } = require('child_process');
23
+
24
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
25
+ const DIRTY_STAT_CAP = 500;
26
+
27
+ function git(root, args) {
28
+ return new Promise((resolve) => {
29
+ execFile('git', args, { cwd: root, maxBuffer: 32 << 20, timeout: 15000, encoding: 'utf8' }, (err, out) => resolve(err ? null : out));
30
+ });
31
+ }
32
+
33
+ /** One string that changes whenever the repository's files change. `null` when the folder cannot be read at all. */
34
+ async function signature(root, listFiles) {
35
+ const head = await git(root, ['rev-parse', 'HEAD']);
36
+ const status = head == null ? null : await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
37
+ const parts = [];
38
+ if (head != null && status != null) {
39
+ parts.push(head.trim(), sha(status));
40
+ const dirty = status.split('\0').filter((e) => e.length > 3).map((e) => e.slice(3)).slice(0, DIRTY_STAT_CAP);
41
+ for (const rel of dirty) {
42
+ try { const st = fs.statSync(path.join(root, rel)); parts.push(rel + ':' + st.mtimeMs + ':' + st.size); } catch { parts.push(rel + ':gone'); }
43
+ }
44
+ return sha(parts.join('\n'));
45
+ }
46
+ // not a git checkout: the mtime and size of every in-scope file
47
+ let files = [];
48
+ try { files = (await listFiles(root)).files; } catch { return null; }
49
+ for (const rel of files) {
50
+ try { const st = fs.statSync(path.join(root, rel)); parts.push(rel + ':' + st.mtimeMs + ':' + st.size); } catch { parts.push(rel + ':gone'); }
51
+ }
52
+ return sha(parts.join('\n'));
53
+ }
54
+
55
+ /**
56
+ * @param {object} o
57
+ * @param {function} o.repos () => [{ id, root }] the repositories that exist
58
+ * @param {function} o.enabled (root) => boolean whether to watch this one
59
+ * @param {function} o.check async ({ root, sweep }) => result one check
60
+ * @param {function} [o.signature] async (root) => string|null
61
+ * @param {function} [o.onResult] ({ id, root, ok, result|error, sweep })
62
+ * @param {function} [o.now] () => ms
63
+ * @param {number} [o.pollMs] how often the timer ticks
64
+ * @param {number} [o.stableMs] how long the signature must hold still before a check
65
+ * @param {number} [o.sweepEveryMs] how often the periodic time-rule pass runs
66
+ */
67
+ function createWatcher(o) {
68
+ const now = o.now || Date.now;
69
+ const pollMs = o.pollMs || 10000;
70
+ const stableMs = o.stableMs == null ? 8000 : o.stableMs;
71
+ const sweepEveryMs = o.sweepEveryMs || 6 * 3600 * 1000;
72
+ const sigOf = o.signature || ((root) => signature(root, o.listFiles || (async () => ({ files: [] }))));
73
+ const state = new Map(); // root → { sig, changedAt, pending, running, lastSweep, checked }
74
+ let timer = null;
75
+ let ticking = false;
76
+
77
+ async function run(id, root, st, sweep) {
78
+ st.running = true;
79
+ try {
80
+ const result = await o.check({ root, sweep });
81
+ if (o.onResult) o.onResult({ id, root, ok: !!(result && result.ok !== false), result, sweep });
82
+ } catch (e) {
83
+ if (o.onResult) o.onResult({ id, root, ok: false, error: String((e && e.message) || e).slice(0, 200), sweep });
84
+ } finally { st.running = false; st.checked = true; if (sweep) st.lastSweep = now(); }
85
+ }
86
+
87
+ /** One pass over every repository. Exposed so a test can drive it with a fake clock. */
88
+ async function tick() {
89
+ if (ticking) return;
90
+ ticking = true;
91
+ try {
92
+ const live = new Set();
93
+ for (const r of o.repos()) {
94
+ if (!o.enabled(r.root)) { state.delete(r.root); continue; }
95
+ live.add(r.root);
96
+ let st = state.get(r.root);
97
+ if (!st) { st = { sig: null, changedAt: null, pending: false, running: false, lastSweep: now(), checked: false }; state.set(r.root, st); }
98
+ if (st.running) continue; // never two checks at once for one repository
99
+ const sig = await sigOf(r.root);
100
+ if (sig == null) continue;
101
+ if (!st.checked && !st.pending && st.sig == null) { st.sig = sig; await run(r.id, r.root, st, false); continue; } // first look: one check
102
+ if (sig !== st.sig) { st.sig = sig; st.changedAt = now(); st.pending = true; continue; }
103
+ if (st.pending && now() - st.changedAt >= stableMs) { st.pending = false; await run(r.id, r.root, st, false); continue; }
104
+ if (!st.pending && now() - st.lastSweep >= sweepEveryMs) await run(r.id, r.root, st, true);
105
+ }
106
+ for (const root of Array.from(state.keys())) if (!live.has(root)) state.delete(root);
107
+ } finally { ticking = false; }
108
+ }
109
+
110
+ function start() {
111
+ if (timer) return stop;
112
+ timer = setInterval(() => { tick().catch(() => { /* a failed tick is retried on the next one */ }); }, pollMs);
113
+ if (timer.unref) timer.unref();
114
+ return stop;
115
+ }
116
+ function stop() { if (timer) { clearInterval(timer); timer = null; } }
117
+ return { tick, start, stop, _state: state };
118
+ }
119
+
120
+ module.exports = { createWatcher, signature };
@@ -245,7 +245,7 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
245
245
  }
246
246
 
247
247
  function usage(say) {
248
- say('usage: pmos schedule <list|show|add|edit|run|enable|disable|rm> … | pmos skill <list|show|new|edit|copy> …');
248
+ say(`usage: pmos schedule <${COMMANDS.schedule.join('|')}> … | pmos skill <${COMMANDS.skill.join('|')}> …`);
249
249
  say(' pmos schedule add --skill gather-requirements --cadence daily --time 07:30 [--until 2026-12-31] [--max-runs 20]');
250
250
  say(' pmos schedule add --from-session <id> --skill-name my-check --cadence weekly --day Fri --time 16:00');
251
251
  say(' pmos schedule edit <id|name> --time 08:00 --args "focus on the billing initiative" --tier deep --until none');
@@ -253,4 +253,7 @@ function usage(say) {
253
253
  say(' schedules fire only while the workspace server is running.');
254
254
  }
255
255
 
256
- module.exports = { run, parseArgs, fieldsFrom };
256
+ // The commands this module answers, in one place: the usage line below and the user guide's drift test both read it.
257
+ const COMMANDS = { schedule: ['list', 'show', 'add', 'edit', 'run', 'enable', 'disable', 'rm'], skill: ['list', 'show', 'new', 'edit', 'copy'] };
258
+
259
+ module.exports = { run, parseArgs, fieldsFrom, COMMANDS };
@@ -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 };
@@ -18,17 +18,60 @@
18
18
 
19
19
  // In the order the PM reads them: what needs them, what is moving, what has not started, what waits on
20
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)
21
27
  const GROUPS = [
22
- { key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue' },
23
- { key: 'in-progress', label: 'In progress', active: true, hint: 'runs logged, nothing merged yet' },
24
- { key: 'planning', label: 'Planning', active: true, hint: 'recorded, no run logged yet' },
25
- { key: 'awaiting-acceptance', label: 'Awaiting acceptance', active: true, hint: 'delivered; the PM has not accepted it' },
26
- { key: 'accepted', label: 'Accepted', active: false, hint: 'accepted by the PM' },
27
- { key: 'on-hold', label: 'On hold', active: false, hint: 'paused by the PM; off the board, not gating' },
28
- { key: 'closed', label: 'Closed', active: false, hint: 'closed by the PM; off the board, not gating' },
29
- { key: 'cancelled', label: 'Cancelled', active: false, hint: 'cancelled by the PM; off the board, not gating' },
30
- { key: 'unknown', label: 'Unknown', active: false, hint: 'the history could not be read, so the status cannot be derived' },
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.' },
31
73
  ];
74
+ const LEGEND_FIELDS = ['meaning', 'source', 'owner', 'moves', 'pmCan'];
32
75
  const BY_KEY = Object.fromEntries(GROUPS.map((g) => [g.key, g]));
33
76
 
34
77
  /** The group key for one derived status row `{status, lifecycle}`; `unknown` when the row is missing or unrecognised. */
@@ -62,5 +105,5 @@
62
105
  .map((g) => ({ ...g, slugs: buckets.get(g.key) }));
63
106
  }
64
107
 
65
- return { GROUPS, BY_KEY, keyOf, group };
108
+ return { GROUPS, BY_KEY, LEGEND_FIELDS, keyOf, group };
66
109
  }));
@@ -0,0 +1,122 @@
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
+ // claims-ledger: keep this repository checked as its files change. Default OFF — switching it on makes the
95
+ // workspace append to the repository's claims.jsonl, so it is the PM's press and never a surprise.
96
+ watch: !!(r && r.watch === true),
97
+ };
98
+ }
99
+
100
+ function setRepo(stateDir, root, { consent, exclude, watch }) {
101
+ const cur = read(stateDir);
102
+ const prev = getRepo(stateDir, root);
103
+ const ex = Array.isArray(exclude)
104
+ ? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
105
+ : prev.exclude;
106
+ const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch } };
107
+ return write(stateDir, { key: cur.key, repos });
108
+ }
109
+
110
+ /** Does `rel` fall under an exclusion? An entry is a path prefix, or a `*` pattern matched on the path. */
111
+ function isExcluded(exclude, rel) {
112
+ const p = String(rel).replace(/\\/g, '/');
113
+ return exclude.some((e) => {
114
+ if (e.includes('*')) {
115
+ const re = new RegExp('^' + e.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
116
+ return re.test(p);
117
+ }
118
+ return p === e || p.startsWith(e.endsWith('/') ? e : e + '/');
119
+ });
120
+ }
121
+
122
+ module.exports = { adoptEnv, status, getKey, setKey, clearKey, getRepo, setRepo, isExcluded, ENV_NAME, FILE };