create-pmos 0.38.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.38.7",
3
+ "version": "0.38.8",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@8c38e46
1
+ kernel-source: pmos@6e5901f
2
2
  assembled: 2026-10-03
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-03T01:20:42Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T03:25:22Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-03T01:20:42Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T03:25:22Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-03T01:20:42Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T03:25:22Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -46,6 +46,41 @@ npm run test:ui # browser checks: terminal geometry, reader scroll, markdow
46
46
  ```
47
47
  </details>
48
48
 
49
+ ## Scheduled tasks from the terminal (`schedule-cli`)
50
+
51
+ `pmos schedule` and `pmos skill` talk to the running workspace server, so the terminal and the workspace share one schedule store and one validator. Every change is saved the moment the command succeeds. A refused value names what is allowed and changes nothing. With no server running the command says so and exits 1.
52
+
53
+ ```
54
+ pmos schedule add --skill gather-requirements --cadence daily --time 07:30 --max-runs 20 --until 2026-12-31
55
+ pmos schedule add --from-session <session-id> --skill-name my-check --output "Summary,Risks" --cadence weekly --day Fri --time 16:00
56
+ pmos schedule add --from-file notes.txt --skill-name my-check --cadence daily # same, from a text file
57
+ pmos schedule list [--all] · show <id|name> · run · enable · disable · rm
58
+ pmos schedule edit <id|name> --time 08:00 --args "focus on billing" --tier deep --until none
59
+ pmos skill list · show <name> · new --name N --description T [--body-file F] · edit <name> --file F · copy <name>
60
+ ```
61
+
62
+ | To change | Use |
63
+ |---|---|
64
+ | Period | `--cadence daily\|weekly\|bi-weekly\|monthly\|once` with `--time HH:MM`, `--day`, `--day-of-month` or `--date` |
65
+ | Duration | `--until YYYY-MM-DD` (that day still runs) and `--max-runs N`. When either is reached the schedule turns itself off and records why. `none` clears one. |
66
+ | Details and input | `--name`, `--args` (up to 2000 characters, added to each run) |
67
+ | How it runs | `--skill`, `--tier fast\|balanced\|deep`, `--profile` |
68
+ | Structure and output | The skill: `pmos skill edit`. Its `output:` list is the contract. Built-in skills are read-only; `pmos skill copy` makes an editable one. |
69
+
70
+ `--from-session` and `--from-file` write a new **user** skill from the captured work (the last 6000 characters of a session's terminal text, escape codes removed) and schedule it. Read the skill before its first run: `pmos skill show <name>`. A session's text can hold anything you typed or printed. The command never overwrites a skill and never uses a built-in's name.
71
+
72
+ **A schedule fires only while the workspace server is running.** A slot missed while it is stopped is skipped, never caught up. A one-off gets a 15-minute grace window. A skill's text is never typed into a session starting with `/`, because Claude Code would read that as a slash command.
73
+
74
+ ## The initiative list and detail: status groups, next steps, conversations, files (`initiative-surface-batch`)
75
+
76
+ **Group by status.** Above the initiative list, `group by objective | status` switches the grouping, and `active only` (in status mode) shows only live work. The groups, in this order: **Needs you** (returned at acceptance, or open items in the needs queue) · **In progress** (runs logged, nothing merged) · **Planning** (recorded, no run yet) · **Awaiting acceptance** (delivered, not accepted) · **Accepted** · **On hold** · **Closed** · **Cancelled** · **Unknown** (the history could not be read, so the status cannot be derived). "Active" is the first four. The status is **derived** from the ledgers and git, never typed into a record. On hold, closed and cancelled are the PM's: the `on hold` / `close` / `cancel` buttons in the initiative detail append one line to `lifecycle.jsonl`, and `reopen` appends another. An active search shows every match whatever its status.
77
+
78
+ **Next steps to done.** The initiative detail lists everything between where it is and accepted, in order: the remaining workflow steps (blocked first), open needs items, a returned acceptance, the sessions running on it, then the PM acceptance (D12). The first is marked `next`. Every item names the fact it came from. It is read from the workflow ladder and the ledgers, not written by a model, so it never names a step the engine does not require. A stopped initiative says so and leads with `reopen`; an accepted one says nothing remains.
79
+
80
+ **Conversations and files in the artifacts.** A session that belongs to an initiative — one you bound, one started on it, or one on its `initiative/<id>` branch — is listed under its artifacts as a conversation. It opens a read-only transcript tab. The transcript is saved when the session ends or is removed, so ending a session no longer erases it. It is kept in this machine's **runtime state directory** (`transcripts/`), not in the repository, and is never sent anywhere; a transcript can contain anything you typed or an agent printed, including a secret. A session on no initiative is not recorded. Below the documents, **files from git** lists what was changed for the initiative: commits on its `initiative/<id>` branch and commits whose subject names it, grouped as code, tests, documents or other, once each, only files that still exist. A failed git read is stated, never shown as an empty list.
81
+
82
+ **Ending a session from the repository navigation** closes its tab. A refused end, or "asked it to end, not exited yet", leaves the tab open.
83
+
49
84
  ## Layout — four panels (PM-directed design, built by `workspace-four-panel`)
50
85
  The three-column layout is retired. The surface is now **four collapsible panels** under a 44px top bar and a 26px status bar, on the oklch token layer, in both themes:
51
86
 
@@ -389,6 +389,13 @@ function doctor() {
389
389
  else if (cmd === 'ls' || cmd === 'list') code = await ls();
390
390
  else if (cmd === 'stop') code = await stop();
391
391
  else if (cmd === 'doctor') code = doctor();
392
+ else if (cmd === 'schedule' || cmd === 'skill') {
393
+ // Thin client of the running server's own routes (lib/schedule-cli.js): one store, one validator.
394
+ const live = await discover();
395
+ const port = live.length ? live[0].port : null;
396
+ code = await require('../lib/schedule-cli').run(process.argv.slice(2), {
397
+ say, call: async (m, p, b) => (port ? api(port, m, p, b) : null) });
398
+ }
392
399
  else if (cmd === 'upgrade') {
393
400
  // Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
394
401
  // to the scaffolder (which owns the install path) rather than reinstalling itself in place.
@@ -400,9 +407,11 @@ function doctor() {
400
407
  if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
401
408
  else code = r.status === null ? 1 : r.status;
402
409
  } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
403
- say('usage: pmos [<path>|status|ls|stop|doctor|upgrade]');
410
+ say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill]');
404
411
  say(' pmos open the current repo as a workspace (starts the server if needed)');
405
412
  say(' pmos <path> open another repo as a workspace');
413
+ say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
414
+ say(' pmos skill … list, read, create and edit skills');
406
415
  say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
407
416
  code = 0;
408
417
  } else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
@@ -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 };