create-pmos 0.38.6 → 0.38.8

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 (40) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/okf/core/templates/report-rubric-template.md +35 -0
  4. package/payload/okf/core/templates/report-weekly-template.md +21 -10
  5. package/payload/planning/evals/eval-calibration.md +1 -1
  6. package/payload/planning/okrs/health-budgets.md +1 -1
  7. package/payload/skills/pm-communication.skill +123 -0
  8. package/payload/skills/pmos-router.skill +1 -0
  9. package/payload/skills/technical-writing-ste.skill +5 -3
  10. package/payload/skills-native/pm-communication/SKILL.md +125 -0
  11. package/payload/skills-native/pmos-router/SKILL.md +1 -0
  12. package/payload/skills-native/technical-writing-ste/SKILL.md +5 -3
  13. package/payload/state/discoveries.md +1 -1
  14. package/workspace/README.md +35 -0
  15. package/workspace/bin/pmos.js +10 -1
  16. package/workspace/lib/initiative-files.js +111 -0
  17. package/workspace/lib/next-steps.js +113 -0
  18. package/workspace/lib/prompt-input.js +18 -0
  19. package/workspace/lib/schedule-cli.js +256 -0
  20. package/workspace/lib/schedule-logic.js +23 -1
  21. package/workspace/lib/skill-store.js +11 -3
  22. package/workspace/lib/status-groups.js +66 -0
  23. package/workspace/lib/transcripts.js +73 -0
  24. package/workspace/package-lock.json +1 -1
  25. package/workspace/package.json +3 -3
  26. package/workspace/public/index.html +2 -0
  27. package/workspace/public/pm/data.js +23 -2
  28. package/workspace/public/pm/modes-live.js +19 -0
  29. package/workspace/public/pm/panel-detail.js +84 -0
  30. package/workspace/public/pm/panel-initiatives.js +37 -1
  31. package/workspace/public/pm/panel-tabs.js +38 -2
  32. package/workspace/public/pm/panel-trees.js +9 -2
  33. package/workspace/public/pm/state.js +7 -2
  34. package/workspace/server.js +112 -5
  35. package/workspace/skills/report-biweekly.skill +5 -5
  36. package/workspace/skills/report-daily.skill +5 -5
  37. package/workspace/skills/report-monthly.skill +5 -5
  38. package/workspace/skills/report-special.skill +3 -3
  39. package/workspace/skills/report-weekly.skill +5 -5
  40. package/workspace/skills/weekly-report.skill +3 -2
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /* initiative-files.js — the files changed FOR an initiative, read from git.
3
+ *
4
+ * The artifact index lists only documents that carry the initiative's name in their header or filename.
5
+ * A source file, a test or an unlabelled note created for the work is therefore invisible there. Git
6
+ * already knows: the commits on the initiative's `initiative/<id>` branch, and trunk commits whose
7
+ * subject names the initiative (PMOS's `type(scope): subject (<id>)` convention). This reads both.
8
+ *
9
+ * It lists a file once, only if it still exists, skips what the document index already shows, and says
10
+ * so when git could not be read — a failed read is never an empty list. Local git only; no network.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const MAX_FILES = 200;
17
+ const cache = new Map(); // root|id → {head, at, result}
18
+ const TTL_MS = 30000;
19
+
20
+ function git(root, args) {
21
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 20000 });
22
+ if (r.error || r.status !== 0) return { ok: false, error: String((r.error && r.error.message) || r.stderr || 'git failed').trim().slice(0, 160) };
23
+ return { ok: true, out: r.stdout };
24
+ }
25
+
26
+ // An id must match as a whole token, so `overhaul-r1` does not claim `overhaul-r12`.
27
+ function namesId(subject, id) {
28
+ const esc = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ return new RegExp('(?<![\\w.-])' + esc + '(?![\\w.-])').test(subject);
30
+ }
31
+
32
+ function groupOf(p) {
33
+ if (/(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[a-z]+$|(^|\/)[\w-]*-test\.[a-z]+$/i.test(p)) return 'tests';
34
+ if (/\.(md|mdx|txt|rst|html)$/i.test(p)) return 'documents';
35
+ if (/\.(js|mjs|cjs|ts|tsx|jsx|py|sh|rb|go|rs|java|css|sql|ya?ml|json)$/i.test(p)) return 'code';
36
+ return 'other';
37
+ }
38
+
39
+ function parseLog(out, keep) {
40
+ // records: \x1e<sha>\x1f<subject>\n<name-status lines>
41
+ const commits = [];
42
+ for (const rec of out.split('\x1e')) {
43
+ if (!rec.trim()) continue;
44
+ const nl = rec.indexOf('\n');
45
+ const head = nl < 0 ? rec : rec.slice(0, nl);
46
+ const [sha, subject] = head.split('\x1f');
47
+ if (!keep(subject || '')) continue;
48
+ const files = [];
49
+ for (const line of (nl < 0 ? '' : rec.slice(nl + 1)).split('\n')) {
50
+ const m = /^([AMRC]\d*)\t(?:.+\t)?(.+)$/.exec(line.trim());
51
+ if (m) files.push(m[2]);
52
+ }
53
+ commits.push({ sha, subject, files });
54
+ }
55
+ return commits;
56
+ }
57
+
58
+ /**
59
+ * @param {string} root repository root
60
+ * @param {string} id initiative id
61
+ * @param {{exclude?:Set<string>}} opts repo-relative paths the document index already lists
62
+ * @returns {{ok:true, items:Array, total:number, truncated:boolean, via:string[]}|{ok:false, reason:string}}
63
+ */
64
+ function filesFor(root, id, opts) {
65
+ const exclude = (opts && opts.exclude) || new Set();
66
+ if (!/^[A-Za-z0-9][\w.-]*$/.test(String(id || ''))) return { ok: false, reason: 'not an initiative id' };
67
+ const headR = git(root, ['rev-parse', 'HEAD']);
68
+ if (!headR.ok) return { ok: false, reason: 'git could not be read here — ' + headR.error };
69
+ const head = headR.out.trim();
70
+ const key = root + '|' + id;
71
+ const hit = cache.get(key);
72
+ let base = hit && hit.head === head && Date.now() - hit.at < TTL_MS ? hit.result : null;
73
+ if (!base) {
74
+ const commits = []; const via = [];
75
+ // 1. trunk and every other ref: commits whose subject names the initiative
76
+ const named = git(root, ['log', '--all', '-F', '--grep=' + id, '-n', '2000', '--no-merges', '--name-status', '--format=%x1e%H%x1f%s']);
77
+ if (!named.ok) return { ok: false, reason: 'git history could not be read — ' + named.error };
78
+ const byName = parseLog(named.out, (s) => namesId(s, id));
79
+ if (byName.length) { via.push('commits naming it'); commits.push(...byName); }
80
+ // 2. the initiative's own branch, local or remote, relative to the trunk
81
+ const branch = ['initiative/' + id, 'origin/initiative/' + id].find((b) => git(root, ['rev-parse', '--verify', '--quiet', b]).ok);
82
+ const trunk = ['origin/main', 'main', 'origin/master', 'master'].find((b) => git(root, ['rev-parse', '--verify', '--quiet', b]).ok);
83
+ if (branch && trunk) {
84
+ const own = git(root, ['log', trunk + '..' + branch, '--no-merges', '--name-status', '--format=%x1e%H%x1f%s']);
85
+ if (own.ok) { const rows = parseLog(own.out, () => true); if (rows.length) { via.push('its branch ' + branch); commits.push(...rows); } }
86
+ }
87
+ // What still exists: the checked-out tree plus the initiative's own branch tip, so a file that lives only on an
88
+ // unmerged branch is listed, and a file deleted since is not. `ls-tree` once per ref, never one spawn per file.
89
+ const present = new Set();
90
+ let treeOk = false;
91
+ for (const ref of ['HEAD', branch].filter(Boolean)) {
92
+ const t = git(root, ['ls-tree', '-r', '--name-only', '-z', ref]);
93
+ if (t.ok) { treeOk = true; for (const f of t.out.split('\0')) if (f) present.add(f); }
94
+ }
95
+ base = { commits, via, present: treeOk ? present : null };
96
+ cache.set(key, { head, at: Date.now(), result: base });
97
+ }
98
+ const seen = new Map();
99
+ for (const c of base.commits) for (const f of c.files) {
100
+ if (exclude.has(f)) continue;
101
+ const prev = seen.get(f);
102
+ if (prev) { if (!prev.shas.includes(c.sha)) prev.shas.push(c.sha); } else seen.set(f, { path: f, shas: [c.sha] });
103
+ }
104
+ const exists = (f) => { if (base.present) return base.present.has(f); try { return fs.existsSync(path.join(root, f)); } catch { return false; } };
105
+ const items = [...seen.values()].filter((x) => exists(x.path))
106
+ .map((x) => ({ path: x.path, group: groupOf(x.path), commits: x.shas.length, last: x.shas[0].slice(0, 7) }))
107
+ .sort((a, b) => (a.group + a.path).localeCompare(b.group + b.path));
108
+ return { ok: true, total: items.length, truncated: items.length > MAX_FILES, items: items.slice(0, MAX_FILES), via: base.via };
109
+ }
110
+
111
+ module.exports = { filesFor, groupOf, namesId };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ /* next-steps.js — what is left between an initiative's present position and "accepted".
3
+ *
4
+ * The workflow engine already derives a ladder: each step is done, current, blocked, pending, na or
5
+ * unknown, and carries the action that would move it. The detail view used to show the one `next`
6
+ * action. This module lists the WHOLE remaining path, in order, from the facts the repository already
7
+ * holds — and invents nothing: every item names the fact it was read from, and a step the engine does
8
+ * not require never appears. Pure: no I/O, no clock, no model. A list a person can check against the
9
+ * ladder is the point; generated advice would be a second opinion about a derived fact.
10
+ *
11
+ * Order (PRD R4): the remaining ladder steps, then open needs items, a returned acceptance, the
12
+ * running sessions, and the PM acceptance. The item the engine names as `next` is marked `first`;
13
+ * with none named, the first item is.
14
+ *
15
+ * Input: { id, status, lifecycle:{state,reason,at}|null, accepted, returned, ladder:{ok,row}|null,
16
+ * needs:[{question,kind}], sessions:[{id,name,state}] }
17
+ * Output: { state: 'accepted'|'stopped'|'open'|'unreadable', summary, items:[…] }
18
+ */
19
+
20
+ const REMAINING = new Set(['blocked', 'current', 'pending', 'unknown']);
21
+
22
+ const clip = (s, n = 200) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
23
+
24
+ function stepItem(s) {
25
+ const unread = s.status === 'unknown';
26
+ return {
27
+ kind: 'step', key: s.key, status: s.status,
28
+ text: unread ? `${s.key} could not be read` : s.key,
29
+ action: clip(s.next || s.evidence || (unread ? 'the workflow engine could not decide this step' : 'no action recorded for this step')),
30
+ why: clip(s.evidence),
31
+ blocking: !!s.blocking,
32
+ source: 'workflow ladder' + (s.rule ? ` — ${clip(s.rule, 120)}` : ''),
33
+ };
34
+ }
35
+
36
+ function deriveNextSteps(input) {
37
+ const i = input || {};
38
+ const row = i.ladder && i.ladder.ok && i.ladder.row ? i.ladder.row : null;
39
+ const steps = row && Array.isArray(row.steps) ? row.steps : [];
40
+ const remaining = steps.filter((s) => REMAINING.has(s.status));
41
+ // blocked first (the engine's own choice of `next`), the rest in ladder order
42
+ const ordered = [...remaining.filter((s) => s.status === 'blocked'), ...remaining.filter((s) => s.status !== 'blocked')];
43
+ const live = (i.sessions || []).filter((s) => s && s.state !== 'exited');
44
+
45
+ if (i.accepted) {
46
+ return { state: 'accepted', summary: 'Accepted by the PM — nothing remains.', items: [] };
47
+ }
48
+
49
+ const stopped = i.lifecycle && i.lifecycle.state && i.lifecycle.state !== 'active' ? i.lifecycle : null;
50
+ if (stopped) {
51
+ const items = [{
52
+ kind: 'reopen', key: 'reopen', status: 'pending',
53
+ text: `Reopen — it is ${stopped.state}`,
54
+ action: 'Reopen it to put it back on the board and have its steps gate again. Nothing below is required while it is stopped.',
55
+ why: clip(stopped.reason), source: 'lifecycle.jsonl (D70)',
56
+ }, ...ordered.map((s) => ({ ...stepItem(s), held: true }))];
57
+ items.forEach((it, n) => { it.n = n + 1; it.first = n === 0; });
58
+ return {
59
+ state: 'stopped',
60
+ summary: `Stopped (${stopped.state})${stopped.reason ? ' — ' + clip(stopped.reason, 120) : ''}. It is off the board and not gating. Reopen to continue; the steps it would then owe are listed after.`,
61
+ items,
62
+ };
63
+ }
64
+
65
+ if (!row) {
66
+ const why = i.ladder && i.ladder.error ? clip(i.ladder.error, 160) : 'no initiative record, so no loop position can be derived';
67
+ return { state: 'unreadable', summary: `No next steps can be listed — ${why}.`, items: [] };
68
+ }
69
+
70
+ // The ladder's own `accept` step is the acceptance: it goes last, after everything the PM would want settled first.
71
+ const acceptStep = ordered.find((s) => s.key === 'accept') || null;
72
+ const items = ordered.filter((s) => s.key !== 'accept').map(stepItem);
73
+ const hasAcceptStep = steps.some((s) => s.key === 'accept');
74
+ for (const n of (i.needs || [])) {
75
+ items.push({
76
+ kind: 'needs', key: 'needs', status: 'pending', text: clip(n.question || '(unstated)', 160),
77
+ action: 'Answer or clear this open item.', why: '', source: 'needs queue',
78
+ });
79
+ }
80
+ if (i.returned) {
81
+ items.push({
82
+ kind: 'returned', key: 'returned', status: 'blocked', text: 'The PM returned this at the Acceptance Gate',
83
+ action: 'Address the return, then request acceptance again.', why: '', source: 'acceptances.jsonl',
84
+ });
85
+ }
86
+ for (const s of live) {
87
+ items.push({
88
+ kind: 'session', key: 'session', status: 'current', text: `Session “${clip(s.name, 60)}” is running on it`,
89
+ action: 'Let it finish, or end it before accepting.', why: '', source: 'live sessions',
90
+ });
91
+ }
92
+ if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', text: 'PM Acceptance' });
93
+ else if (!hasAcceptStep) {
94
+ items.push({
95
+ kind: 'accept', key: 'accept', status: 'pending', text: 'PM Acceptance',
96
+ action: 'The PM names one thing verified by hand, then records the acceptance (D12). It is the PM’s and cannot be delegated.',
97
+ why: '', source: 'Acceptance Gate (D12)',
98
+ });
99
+ }
100
+
101
+ const engineNext = row.next && row.next.step ? row.next.step : null;
102
+ const firstIdx = engineNext ? Math.max(0, items.findIndex((x) => x.kind === 'step' && x.key === engineNext)) : 0;
103
+ items.forEach((it, n) => { it.n = n + 1; it.first = n === firstIdx; });
104
+ if (!items.length) return { state: 'open', summary: 'No step is outstanding — the ladder shows every required step done. Acceptance is the PM’s.', items: [] };
105
+ const blockedN = items.filter((x) => x.status === 'blocked').length;
106
+ return {
107
+ state: 'open',
108
+ summary: `${items.length} thing${items.length === 1 ? '' : 's'} left to reach acceptance${blockedN ? `, ${blockedN} blocked` : ''}. Start with step ${firstIdx + 1}.`,
109
+ items,
110
+ };
111
+ }
112
+
113
+ module.exports = { deriveNextSteps };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ /* prompt-input.js — what the workspace types into an agent session as its first input.
3
+ *
4
+ * Claude Code reads input that begins with `/` as a slash command. Every agent skill body opens
5
+ * `/<skill-name> — …`, so a scheduled run of `skill-discover` was answered "Unknown command:
6
+ * /skill-discover" and the session did no work, while the schedule recorded `started`. The skill
7
+ * files keep their wording; the fix is here, at the one point where a body becomes typed input.
8
+ * A text that does not begin with `/` is returned unchanged. Pure: no I/O.
9
+ */
10
+ const LEADING_SLASH = /^\s*\//;
11
+ const PREFIX = 'Follow this skill: ';
12
+
13
+ function safeFirstInput(text) {
14
+ const s = String(text == null ? '' : text);
15
+ return LEADING_SLASH.test(s) ? PREFIX + s.replace(/^\s+/, '') : s;
16
+ }
17
+
18
+ module.exports = { safeFirstInput, PREFIX };
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+ /* schedule-cli.js — `pmos schedule …` and `pmos skill …`: the terminal face of the scheduler.
3
+ *
4
+ * Every command is a thin client of the running workspace server's own routes, so the terminal and the
5
+ * workspace can never disagree about a schedule: one store, one validator, one fire path. Nothing here
6
+ * writes a file itself. A refused value is reported with the allowed values and nothing is changed,
7
+ * because the server checks before it writes. With no server running the command says so and exits 1.
8
+ *
9
+ * pmos schedule list [--all]
10
+ * pmos schedule show <id|name>
11
+ * pmos schedule add --skill S --cadence C [fields] fields: --name --day --day-of-month --time --date
12
+ * pmos schedule add --from-session ID|--from-file PATH --skill-name N [--output "A,B"] [--description T] [fields]
13
+ * pmos schedule edit <id|name> [fields] [--skill S] fields also: --until --max-runs ('none' clears one)
14
+ * pmos schedule run|enable|disable|rm <id|name>
15
+ * pmos skill list | show <name> | new --name N --description T [--output "A,B"] [--body-file F]
16
+ * pmos skill edit <name> --file F | copy <name>
17
+ *
18
+ * Pure of sockets: the caller passes `call` (an HTTP client) and `say`, so the whole thing is testable.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execFileSync } = require('child_process');
23
+
24
+ const FLAG_FIELDS = {
25
+ name: 'name', skill: 'skill', cadence: 'cadence', day: 'day', 'day-of-month': 'dayOfMonth', time: 'time',
26
+ date: 'date', args: 'args', tier: 'tier', profile: 'profile', until: 'until', 'max-runs': 'max_runs',
27
+ };
28
+ const BOOLEAN_FLAGS = new Set(['all', 'help']);
29
+
30
+ function parseArgs(argv) {
31
+ const pos = []; const flags = {};
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ if (a.startsWith('--')) {
35
+ const eq = a.indexOf('=');
36
+ const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
37
+ if (BOOLEAN_FLAGS.has(key)) { flags[key] = true; continue; }
38
+ if (eq > 0) { flags[key] = a.slice(eq + 1); continue; }
39
+ const next = argv[i + 1];
40
+ if (next === undefined || (next.startsWith('--') && next.length > 2)) { flags[key] = ''; continue; }
41
+ flags[key] = next; i++;
42
+ } else pos.push(a);
43
+ }
44
+ return { pos, flags };
45
+ }
46
+
47
+ // The fields a command sets, as the server names them. 'none' clears until/max_runs/tier/profile/args.
48
+ function fieldsFrom(flags) {
49
+ const out = {};
50
+ for (const [flag, key] of Object.entries(FLAG_FIELDS)) {
51
+ if (!(flag in flags)) continue;
52
+ const v = flags[flag];
53
+ const clearable = ['until', 'max_runs', 'tier', 'profile', 'args'].includes(key);
54
+ out[key] = clearable && (v === 'none' || v === '') ? null : v;
55
+ }
56
+ if (out.max_runs != null && /^\d+$/.test(String(out.max_runs))) out.max_runs = +out.max_runs;
57
+ if (out.dayOfMonth != null && /^\d+$/.test(String(out.dayOfMonth))) out.dayOfMonth = +out.dayOfMonth;
58
+ return out;
59
+ }
60
+
61
+ function gitRoot(dir) {
62
+ try { return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
63
+ catch { return path.resolve(dir); }
64
+ }
65
+
66
+ function describe(s) {
67
+ const when = s.cadence === 'once' ? `once, ${s.date || '?'} at ${s.time || '09:00'}`
68
+ : s.cadence === 'weekly' ? `every ${s.day || 'Mon'} at ${s.time}`
69
+ : s.cadence === 'bi-weekly' ? `every other ${s.day || 'Mon'} at ${s.time}`
70
+ : s.cadence === 'monthly' ? `monthly on day ${s.dayOfMonth || 1} at ${s.time}`
71
+ : `every day at ${s.time}`;
72
+ return when;
73
+ }
74
+
75
+ function showLines(s) {
76
+ const lines = [
77
+ `${s.name} (${s.id})`,
78
+ ` skill ${s.skill}`,
79
+ ` when ${describe(s)} · ${s.enabled ? 'enabled' : 'disabled'}${s.endedReason ? ' — ended: ' + s.endedReason : ''}`,
80
+ ` next run ${s.enabled && s.next ? new Date(s.next).toLocaleString() : '—'}`,
81
+ ` last run ${s.lastRun ? new Date(s.lastRun).toLocaleString() + ' [' + (s.lastStatus || '?') + ']' : 'never'}`,
82
+ ` runs ${+s.runs || 0}${s.max_runs ? ' of ' + s.max_runs : ''}${s.until ? ' · until ' + s.until + ' (that day included)' : ''}`,
83
+ ];
84
+ if (s.args) lines.push(` args ${s.args}`);
85
+ if (s.tier) lines.push(` tier ${s.tier}`);
86
+ if (s.profile) lines.push(` profile ${s.profile}`);
87
+ lines.push(' note this schedule fires only while the workspace server is running; a slot missed while it is stopped is skipped, not caught up.');
88
+ return lines;
89
+ }
90
+
91
+ async function run(argv, { call, say, cwd = process.cwd() }) {
92
+ const [group, verb, ...rest] = argv;
93
+ const { pos, flags } = parseArgs(rest);
94
+ if (!group || flags.help || group === 'help') { usage(say); return group ? 0 : 1; }
95
+ if (!['schedule', 'skill'].includes(group)) { say(`✗ unknown command "${group}" — use schedule or skill`); return 1; }
96
+
97
+ // The server first: with none running, every command is the same one honest sentence.
98
+ const probe = await call('GET', '/api/schedules?all=1');
99
+ if (!probe) { say('✗ the workspace server is not running — start it with: pmos'); return 1; }
100
+
101
+ const fail = (msg) => { say('✗ ' + msg); return 1; };
102
+ const refused = (r, what) => fail(`${what} refused — ${(r && r.error) || 'the server did not answer'}. Nothing was changed.`);
103
+
104
+ async function repoWs() {
105
+ const root = gitRoot(flags.repo ? path.resolve(flags.repo) : cwd);
106
+ const wl = await call('GET', '/api/workspaces');
107
+ const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === root);
108
+ return hit ? { id: hit.id, name: hit.name, root } : { missing: root };
109
+ }
110
+ async function pick(ref) {
111
+ if (!ref) return { err: 'say which schedule: its id or its name (pmos schedule list)' };
112
+ const all = ((await call('GET', '/api/schedules?all=1')) || {}).schedules || [];
113
+ const byId = all.filter((s) => s.id === ref || s.id.startsWith(ref));
114
+ const hits = byId.length ? byId : all.filter((s) => s.name.toLowerCase() === ref.toLowerCase());
115
+ if (hits.length === 1) return { s: hits[0] };
116
+ return { err: hits.length ? `"${ref}" matches ${hits.length} schedules — use the id` : `no schedule "${ref}" (pmos schedule list)` };
117
+ }
118
+
119
+ if (group === 'schedule') {
120
+ if (verb === 'list' || !verb) {
121
+ let list = probe.schedules || [];
122
+ if (!flags.all) {
123
+ const w = await repoWs();
124
+ if (!w.missing) list = list.filter((s) => s.ws === w.id);
125
+ }
126
+ if (!list.length) { say('no scheduled tasks here (pmos schedule add …, or --all for every repository)'); return 0; }
127
+ for (const s of list) say(`${s.enabled ? '●' : '○'} ${s.id} ${s.name} · ${s.skill} · ${describe(s)}${s.endedReason ? ' · ended: ' + s.endedReason : ''}`);
128
+ return 0;
129
+ }
130
+ if (verb === 'show') {
131
+ const { s, err } = await pick(pos[0]); if (err) return fail(err);
132
+ showLines(s).forEach((l) => say(l)); return 0;
133
+ }
134
+ if (verb === 'add') {
135
+ const w = await repoWs();
136
+ if (w.missing) return fail(`${w.missing} is not a registered workspace — run \`pmos\` in it once, or pass --repo of one that is`);
137
+ const f = fieldsFrom(flags);
138
+ let created = null;
139
+ if (flags['from-session'] || flags['from-file']) {
140
+ const skillName = flags['skill-name'];
141
+ if (!skillName) return fail('--from-session and --from-file need --skill-name, the name for the new skill');
142
+ let work = '';
143
+ if (flags['from-file']) { try { work = fs.readFileSync(path.resolve(cwd, flags['from-file']), 'utf8'); } catch { return fail(`cannot read ${flags['from-file']}`); } }
144
+ else {
145
+ const t = await call('GET', '/api/sessions/transcript?id=' + encodeURIComponent(flags['from-session']) + '&max=6000');
146
+ if (!t || !t.ok) return refused(t, 'reading the session');
147
+ work = t.text;
148
+ }
149
+ if (!work.trim()) return fail('the source is empty — there is nothing to turn into a skill');
150
+ const out = flags.output || '';
151
+ const sk = await call('POST', '/api/skills', { name: skillName, description: flags.description || `Repeat: ${skillName}`, output: out, work, ws: w.id });
152
+ if (!sk || !sk.ok) return refused(sk, 'creating the skill');
153
+ created = sk.skill.id;
154
+ f.skill = created;
155
+ say(`✓ skill "${created}" written (user skill). Review it: pmos skill show ${created}`);
156
+ }
157
+ if (!f.skill) return fail('say which skill: --skill <name> (pmos skill list), or --from-session / --from-file');
158
+ if (!f.cadence) return fail('say when: --cadence daily|weekly|bi-weekly|monthly|once (with --time HH:MM, --day, --day-of-month or --date)');
159
+ f.name = f.name || (created ? `${created}` : f.skill);
160
+ f.task = f.skill;
161
+ const r = await call('POST', '/api/schedules', { ...f, ws: w.id });
162
+ if (!r || !r.ok) return refused(r, 'adding the schedule');
163
+ const s = r.schedule;
164
+ say(`✓ scheduled "${s.name}" (${s.id}) in ${w.name}: ${describe(s)}`);
165
+ say(` next run ${s.nextRun ? new Date(s.nextRun).toLocaleString() : '—'} — fires only while the workspace server is running`);
166
+ return 0;
167
+ }
168
+ if (verb === 'edit') {
169
+ const { s, err } = await pick(pos[0]); if (err) return fail(err);
170
+ const f = fieldsFrom(flags);
171
+ if (f.skill) f.task = f.skill;
172
+ if (!Object.keys(f).length) return fail('nothing to change — pass a field, e.g. --time 08:00 or --max-runs 10');
173
+ const r = await call('POST', '/api/schedules/update', { id: s.id, ...f });
174
+ if (!r || !r.ok) return refused(r, 'the edit');
175
+ say(`✓ saved "${r.schedule.name}": ${describe(r.schedule)}`);
176
+ return 0;
177
+ }
178
+ if (verb === 'enable' || verb === 'disable') {
179
+ const { s, err } = await pick(pos[0]); if (err) return fail(err);
180
+ const r = await call('POST', '/api/schedules/update', { id: s.id, enabled: verb === 'enable' });
181
+ if (!r || !r.ok) return refused(r, verb);
182
+ say(`✓ "${r.schedule.name}" is ${r.schedule.enabled ? 'enabled' : 'disabled'}`);
183
+ return 0;
184
+ }
185
+ if (verb === 'run') {
186
+ const { s, err } = await pick(pos[0]); if (err) return fail(err);
187
+ const r = await call('POST', '/api/schedules/run', { id: s.id });
188
+ const res = r && r.result;
189
+ if (!r || !r.ok || !res || res.ok === false) return fail(`"${s.name}" did not start — ${(res && res.reason) || (r && r.error) || 'no answer'}`);
190
+ say(res.sessionId ? `✓ started session ${res.sessionId} for "${s.name}"` : `✓ ran "${s.name}"`);
191
+ return 0;
192
+ }
193
+ if (verb === 'rm' || verb === 'remove' || verb === 'delete') {
194
+ const { s, err } = await pick(pos[0]); if (err) return fail(err);
195
+ const r = await call('POST', '/api/schedules/delete', { id: s.id });
196
+ if (!r || !r.ok) return refused(r, 'removing');
197
+ say(`✓ removed "${s.name}"`);
198
+ return 0;
199
+ }
200
+ return fail(`unknown schedule command "${verb}" — list, show, add, edit, run, enable, disable, rm`);
201
+ }
202
+
203
+ // skill
204
+ const w = await repoWs();
205
+ const wsq = w.missing ? '' : '&ws=' + encodeURIComponent(w.id);
206
+ if (verb === 'list' || !verb) {
207
+ const r = await call('GET', '/api/skills' + (wsq ? '?' + wsq.slice(1) : ''));
208
+ for (const k of (r && r.skills) || []) say(`${k.source === 'user' ? '✎' : '·'} ${k.id} [${k.source}] ${k.description}`);
209
+ return 0;
210
+ }
211
+ if (verb === 'show') {
212
+ if (!pos[0]) return fail('say which skill: pmos skill show <name>');
213
+ const r = await call('GET', '/api/skills/read?id=' + encodeURIComponent(pos[0]) + wsq);
214
+ if (!r || !r.ok) return refused(r, 'reading the skill');
215
+ say(`# ${r.id} — ${r.source}${r.editable ? ' (yours — edit with: pmos skill edit ' + r.id + ' --file <path>)' : ' (read-only — pmos skill copy ' + r.id + ')'}`);
216
+ say(r.text);
217
+ return 0;
218
+ }
219
+ if (verb === 'new') {
220
+ if (!flags.name) return fail('--name is required');
221
+ let body = '';
222
+ if (flags['body-file']) { try { body = fs.readFileSync(path.resolve(cwd, flags['body-file']), 'utf8'); } catch { return fail(`cannot read ${flags['body-file']}`); } }
223
+ const r = await call('POST', '/api/skills', { name: flags.name, description: flags.description || '', output: flags.output || '', body, ws: w.missing ? undefined : w.id });
224
+ if (!r || !r.ok) return refused(r, 'creating the skill');
225
+ say(`✓ skill "${r.skill.id}" written. Edit it: pmos skill edit ${r.skill.id} --file <path>`);
226
+ return 0;
227
+ }
228
+ if (verb === 'edit') {
229
+ if (!pos[0] || !flags.file) return fail('usage: pmos skill edit <name> --file <path> (the whole new skill text; "-" reads stdin)');
230
+ let text;
231
+ try { text = flags.file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'); } catch { return fail(`cannot read ${flags.file}`); }
232
+ const r = await call('POST', '/api/skills/save', { id: pos[0], text });
233
+ if (!r || !r.ok) return refused(r, 'saving the skill');
234
+ say(`✓ saved "${r.id}"`);
235
+ return 0;
236
+ }
237
+ if (verb === 'copy') {
238
+ if (!pos[0]) return fail('say which skill: pmos skill copy <name>');
239
+ const r = await call('POST', '/api/skills/copy', { id: pos[0], ws: w.missing ? undefined : w.id });
240
+ if (!r || !r.ok) return refused(r, 'copying');
241
+ say(`✓ copied as "${r.id}" — yours to edit`);
242
+ return 0;
243
+ }
244
+ return fail(`unknown skill command "${verb}" — list, show, new, edit, copy`);
245
+ }
246
+
247
+ function usage(say) {
248
+ say('usage: pmos schedule <list|show|add|edit|run|enable|disable|rm> … | pmos skill <list|show|new|edit|copy> …');
249
+ say(' pmos schedule add --skill gather-requirements --cadence daily --time 07:30 [--until 2026-12-31] [--max-runs 20]');
250
+ say(' pmos schedule add --from-session <id> --skill-name my-check --cadence weekly --day Fri --time 16:00');
251
+ say(' pmos schedule edit <id|name> --time 08:00 --args "focus on the billing initiative" --tier deep --until none');
252
+ say(' fields: --name --skill --cadence --day --day-of-month --time --date --args --tier --profile --until --max-runs --repo');
253
+ say(' schedules fire only while the workspace server is running.');
254
+ }
255
+
256
+ module.exports = { run, parseArgs, fieldsFrom };
@@ -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 };
@@ -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.".',