create-pmos 0.38.7 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +64 -1
- package/workspace/bin/pmos.js +10 -1
- package/workspace/guide/index.md +38 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +59 -0
- package/workspace/guide/scheduled-tasks-and-cli.md +41 -0
- package/workspace/guide/statuses.md +53 -0
- package/workspace/guide/validate-with-typesafe.md +47 -0
- package/workspace/lib/initiative-files.js +134 -0
- package/workspace/lib/judgment.js +65 -0
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/next-step-actions.js +139 -0
- package/workspace/lib/next-steps.js +133 -0
- package/workspace/lib/prompt-input.js +18 -0
- package/workspace/lib/schedule-cli.js +259 -0
- package/workspace/lib/schedule-logic.js +23 -1
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/skill-store.js +11 -3
- package/workspace/lib/status-groups.js +109 -0
- package/workspace/lib/transcripts.js +73 -0
- package/workspace/lib/typesafe-settings.js +119 -0
- package/workspace/lib/typesafe.js +124 -0
- package/workspace/lib/validate.js +304 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +2 -0
- package/workspace/public/pm/app.js +53 -18
- package/workspace/public/pm/data.js +41 -5
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +171 -0
- package/workspace/public/pm/modes.js +25 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +121 -1
- package/workspace/public/pm/panel-initiatives.js +61 -3
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/panel-tabs.js +38 -2
- package/workspace/public/pm/panel-trees.js +9 -2
- package/workspace/public/pm/state.js +9 -4
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +11 -6
- package/workspace/server.js +434 -8
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* judgment.js — typed answers to classify, score and yes-or-no questions.
|
|
3
|
+
*
|
|
4
|
+
* The workspace makes no network call and runs no model. When the PM turns judgment on for a run, the PROMPT tells the
|
|
5
|
+
* session to use the Typesafe skill (https://docs.typesafe.ai/agent-skill; needs TYPESAFE_API_KEY) for these three kinds
|
|
6
|
+
* of decision, and to print each answer on one line as `JUDGMENT {json}`. This module owns the fragment that says so and
|
|
7
|
+
* the check of the shape that comes back. An answer in any other shape is rejected, never repaired.
|
|
8
|
+
*
|
|
9
|
+
* Off, or without a key, the session is asked for the SAME shape from plain judgment, and the fragment says so — the
|
|
10
|
+
* PM never gets a different answer format depending on a setting.
|
|
11
|
+
*/
|
|
12
|
+
const KINDS = ['classify', 'score', 'yesno'];
|
|
13
|
+
const LINE = /^JUDGMENT\s+(\{.*\})\s*$/;
|
|
14
|
+
const clip = (s, n) => String(s).slice(0, n);
|
|
15
|
+
|
|
16
|
+
function fragment(on) {
|
|
17
|
+
const shape = [
|
|
18
|
+
'When a step needs you to categorise, score, or decide yes or no, give the answer on its own line, exactly like this, one JSON object per line:',
|
|
19
|
+
// The examples start with "e.g." so a transcript that echoes this prompt is never read as an answer.
|
|
20
|
+
' e.g. JUDGMENT {"kind":"classify","question":"…","label":"<one of your labels>","labels":["…","…"],"confidence":0.0-1.0}',
|
|
21
|
+
' e.g. JUDGMENT {"kind":"score","question":"…","score":0.0-1.0,"reason":"…"}',
|
|
22
|
+
' e.g. JUDGMENT {"kind":"yesno","question":"…","answer":true|false,"confidence":0.0-1.0,"reason":"…"}',
|
|
23
|
+
];
|
|
24
|
+
if (on) return ['Use the Typesafe skill (/typesafe:typesafe-ai) for those decisions. The question text you send leaves this machine for the Typesafe service and needs TYPESAFE_API_KEY. If the skill or the key is missing, say so and decide yourself.', ...shape].join('\n');
|
|
25
|
+
return ['Decide those yourself; no outside service is used for this run.', ...shape].join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const unit = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
29
|
+
|
|
30
|
+
/** @returns {{ok:true,value:object}|{ok:false,error:string}} */
|
|
31
|
+
function validate(o) {
|
|
32
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return { ok: false, error: 'not an object' };
|
|
33
|
+
if (!KINDS.includes(o.kind)) return { ok: false, error: `kind must be one of ${KINDS.join(', ')}` };
|
|
34
|
+
if (typeof o.question !== 'string' || !o.question.trim()) return { ok: false, error: 'question is required' };
|
|
35
|
+
const base = { kind: o.kind, question: clip(o.question.trim(), 300) };
|
|
36
|
+
if (o.kind === 'classify') {
|
|
37
|
+
if (!Array.isArray(o.labels) || o.labels.length < 2 || o.labels.some((l) => typeof l !== 'string' || !l.trim())) return { ok: false, error: 'labels must be a list of at least two strings' };
|
|
38
|
+
if (typeof o.label !== 'string' || !o.labels.includes(o.label)) return { ok: false, error: 'label must be one of labels' };
|
|
39
|
+
if (!unit(o.confidence)) return { ok: false, error: 'confidence must be a number from 0 to 1' };
|
|
40
|
+
return { ok: true, value: { ...base, label: o.label, labels: o.labels.map((l) => clip(l, 60)), confidence: o.confidence } };
|
|
41
|
+
}
|
|
42
|
+
if (o.kind === 'score') {
|
|
43
|
+
if (!unit(o.score)) return { ok: false, error: 'score must be a number from 0 to 1' };
|
|
44
|
+
if (typeof o.reason !== 'string' || !o.reason.trim()) return { ok: false, error: 'reason is required' };
|
|
45
|
+
return { ok: true, value: { ...base, score: o.score, reason: clip(o.reason.trim(), 300) } };
|
|
46
|
+
}
|
|
47
|
+
if (typeof o.answer !== 'boolean') return { ok: false, error: 'answer must be true or false' };
|
|
48
|
+
if (!unit(o.confidence)) return { ok: false, error: 'confidence must be a number from 0 to 1' };
|
|
49
|
+
return { ok: true, value: { ...base, answer: o.answer, confidence: o.confidence, ...(typeof o.reason === 'string' ? { reason: clip(o.reason.trim(), 300) } : {}) } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every `JUDGMENT {json}` line in a transcript, checked. Returns { valid:[…], invalid:[{line,error}] }. */
|
|
53
|
+
function extract(text) {
|
|
54
|
+
const valid = []; const invalid = [];
|
|
55
|
+
for (const raw of String(text == null ? '' : text).split(/\r?\n/)) {
|
|
56
|
+
const m = LINE.exec(raw.trim());
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
let o; try { o = JSON.parse(m[1]); } catch { invalid.push({ line: clip(raw.trim(), 200), error: 'not valid JSON' }); continue; }
|
|
59
|
+
const v = validate(o);
|
|
60
|
+
if (v.ok) valid.push(v.value); else invalid.push({ line: clip(raw.trim(), 200), error: v.error });
|
|
61
|
+
}
|
|
62
|
+
return { valid, invalid };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { KINDS, fragment, validate, extract };
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
// silently renders a key where a title belongs.
|
|
50
50
|
const MODE_TITLE = {
|
|
51
51
|
product: 'initiatives', scheduled: 'scheduled tasks', workflow: 'workflows', skills: 'skills',
|
|
52
|
-
knowledge: 'knowledge', agent: 'agents', memory: 'memory',
|
|
52
|
+
knowledge: 'knowledge', agent: 'agents', memory: 'memory', portfolio: 'portfolio', guide: 'guide',
|
|
53
53
|
};
|
|
54
54
|
// What a DETAIL screen is called. It moved here with the reducer, for the same reason.
|
|
55
55
|
// The second block is the four-panel surface's `detail.kind` vocabulary (pm/app.js's DETAIL_TITLE).
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* next-step-actions.js — what a next step can DO, whether it may be started yet, and the prompt a session would get.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no I/O, no clock, no model. The server supplies facts (does a file exist, which sources a profile declares) and
|
|
5
|
+
* this module decides. Three questions, each answered in one place so no panel and no route re-decides them:
|
|
6
|
+
*
|
|
7
|
+
* actFor(item) what kind of act the step is: open · session · work · gate · lifecycle · none
|
|
8
|
+
* refusal(item, ns) why a `work` step may NOT be started yet, or null when it may
|
|
9
|
+
* buildPrompt(...) the exact first input a session would receive — what the PM is shown before pressing go
|
|
10
|
+
*
|
|
11
|
+
* THE BOUNDARY (D12 and the control/factory invariant). A session started from here PROPOSES: it commits to a local
|
|
12
|
+
* initiative branch or writes draft files. It never accepts, approves a contract, merges, pushes, sends or posts. Acceptance
|
|
13
|
+
* and the contract approval are `gate` acts and are never turned into `work`.
|
|
14
|
+
*/
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// Ladder steps an agent session can do. `contract`, `accept`, `approved`, `outcome` and `handoff` are the PM's own act.
|
|
18
|
+
const WORK_KEYS = new Set(['route', 'anchor', 'prototype', 'understand', 'spec', 'priors', 'build', 'quality', 'review', 'explain', 'brief', 'draft']);
|
|
19
|
+
const GATE_KEYS = new Set(['contract', 'accept', 'approved', 'outcome', 'handoff']);
|
|
20
|
+
const KNOWN_KEYS = [...WORK_KEYS, ...GATE_KEYS];
|
|
21
|
+
// The act types a step can have (`none` means no action is defined, and is not offered). The user guide names each one.
|
|
22
|
+
const ACT_TYPES = ['open', 'session', 'work', 'gate', 'lifecycle'];
|
|
23
|
+
|
|
24
|
+
const GATE_WHY = {
|
|
25
|
+
contract: 'The contract is yours to approve. An agent may draft it; the approval is recorded by you, never by a session.',
|
|
26
|
+
accept: 'Acceptance is yours and cannot be delegated (D12).',
|
|
27
|
+
approved: 'Approval is yours.',
|
|
28
|
+
outcome: 'The outcome check is yours to record.',
|
|
29
|
+
handoff: 'Handing work to another system is outward-facing, so it is yours to press.',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @returns {{type:'open'|'session'|'work'|'gate'|'lifecycle'|'none', why:string, path?:string, sessionId?:string}} */
|
|
33
|
+
function actFor(item) {
|
|
34
|
+
if (!item || typeof item !== 'object') return { type: 'none', why: 'no step' };
|
|
35
|
+
const path = typeof item.path === 'string' && item.path ? item.path : null;
|
|
36
|
+
const withPath = (a) => (path ? { ...a, path } : a);
|
|
37
|
+
switch (item.kind) {
|
|
38
|
+
case 'reopen': return { type: 'lifecycle', why: 'Reopen it to put it back on the board.' };
|
|
39
|
+
case 'session': return item.sid ? { type: 'session', why: 'Switch to the running session.', sessionId: item.sid } : { type: 'none', why: 'the session is not identified' };
|
|
40
|
+
case 'accept': return withPath({ type: path ? 'open' : 'gate', why: GATE_WHY.accept });
|
|
41
|
+
case 'needs': return withPath({ type: 'gate', why: 'An open item only you can answer.' });
|
|
42
|
+
case 'returned': return { type: 'work', why: 'Address the return, then ask for acceptance again.' };
|
|
43
|
+
case 'step': {
|
|
44
|
+
if (WORK_KEYS.has(item.key)) return withPath({ type: 'work', why: 'An agent session can do this step.' });
|
|
45
|
+
if (GATE_KEYS.has(item.key)) return withPath({ type: path ? 'open' : 'gate', why: GATE_WHY[item.key] });
|
|
46
|
+
return { type: 'none', why: `no action is defined for the step "${item.key}"` };
|
|
47
|
+
}
|
|
48
|
+
default: return { type: 'none', why: 'no action is defined for this item' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Set `act` on every item of a derived next-steps result. Mutates and returns it. */
|
|
53
|
+
function annotate(ns, pathOf) {
|
|
54
|
+
if (!ns || !Array.isArray(ns.items)) return ns;
|
|
55
|
+
for (const it of ns.items) {
|
|
56
|
+
if (typeof pathOf === 'function') { const p = pathOf(it); if (p) it.path = p; }
|
|
57
|
+
it.act = actFor(it);
|
|
58
|
+
}
|
|
59
|
+
return ns;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Why this item may not be started as work yet; null when it may. `ns` is the whole derived result. */
|
|
63
|
+
function refusal(item, ns) {
|
|
64
|
+
if (!item) return { code: 'no-step', why: 'no such step' };
|
|
65
|
+
const act = item.act || actFor(item);
|
|
66
|
+
if (ns && ns.state === 'stopped' && item.kind !== 'reopen') {
|
|
67
|
+
return { code: 'stopped', why: 'This initiative is stopped (held, closed or cancelled). Reopen it first.', first: 'reopen' };
|
|
68
|
+
}
|
|
69
|
+
if (ns && ns.state === 'accepted') return { code: 'accepted', why: 'This initiative is accepted. Nothing remains.' };
|
|
70
|
+
if (act.type !== 'work') return { code: 'not-work', why: act.type === 'gate' ? act.why : `This is not work an agent session can start (${act.type}).` };
|
|
71
|
+
const items = (ns && ns.items) || [];
|
|
72
|
+
if (item.kind === 'returned') return null; // addressing a return is always the next thing
|
|
73
|
+
if (item.status === 'blocked') return { code: 'blocked', why: `This step is blocked${item.why ? ' — ' + item.why : ''}.`, first: item.key };
|
|
74
|
+
if (item.key === 'build') {
|
|
75
|
+
const contract = items.find((x) => x.kind === 'step' && x.key === 'contract');
|
|
76
|
+
if (contract) return { code: 'no-contract', why: 'The build cannot start before you approve the pre-run contract. An agent may draft it; you approve it.', first: 'contract' };
|
|
77
|
+
}
|
|
78
|
+
if (item.first || item.status === 'current') return null;
|
|
79
|
+
const first = items.find((x) => x.first) || items[0];
|
|
80
|
+
return { code: 'not-reached', why: `Not reached yet: step ${first ? first.n : 1} (${first ? first.key : 'the first step'}) comes first.`, first: first ? first.key : null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Which read-only sources suit which step. Names are the keys of SOURCE_MCP below.
|
|
84
|
+
const STEP_SOURCES = {
|
|
85
|
+
route: ['jira', 'slack'], understand: ['wispr', 'confluence', 'jira', 'slack'], spec: ['wispr', 'confluence', 'jira', 'gmail'],
|
|
86
|
+
priors: ['confluence'], prototype: ['wispr', 'slack'], build: ['jira', 'confluence'], returned: ['wispr', 'slack', 'gmail'],
|
|
87
|
+
brief: ['wispr', 'calendar', 'gmail'], draft: ['wispr', 'confluence', 'gmail'],
|
|
88
|
+
};
|
|
89
|
+
// MCP server names a profile may declare, per source. A name outside this table names nothing.
|
|
90
|
+
const SOURCE_MCP = {
|
|
91
|
+
wispr: ['wispr', 'wispr-flow', 'wisprflow'], gmail: ['gmail', 'google-mail'], calendar: ['google-calendar', 'gcal', 'calendar'],
|
|
92
|
+
confluence: ['confluence', 'atlassian'], jira: ['jira', 'atlassian'], slack: ['slack'],
|
|
93
|
+
};
|
|
94
|
+
const SOURCE_LABEL = { wispr: 'Wispr Flow notes and meetings', gmail: 'Gmail', calendar: 'Google Calendar', confluence: 'Confluence', jira: 'Jira', slack: 'Slack' };
|
|
95
|
+
|
|
96
|
+
/** The sources the prompt may name: suited to the step AND declared by the chosen profile's `mcp` list. */
|
|
97
|
+
function sourcesFor(item, mcpDeclared) {
|
|
98
|
+
const declared = new Set((Array.isArray(mcpDeclared) ? mcpDeclared : []).map((s) => String(s).toLowerCase()));
|
|
99
|
+
const want = STEP_SOURCES[item && item.kind === 'returned' ? 'returned' : item && item.key] || [];
|
|
100
|
+
return want.filter((s) => (SOURCE_MCP[s] || []).some((n) => declared.has(n))).map((s) => ({ id: s, label: SOURCE_LABEL[s] }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RULES = [
|
|
104
|
+
'Commit only to this local branch, or write draft files in the repository.',
|
|
105
|
+
'Do not push, open a pull request, send or post anything, create a ticket, record an acceptance, approve a contract, or merge. Those are the PM\'s.',
|
|
106
|
+
'Do only this step. If you find another problem, write it down and stop.',
|
|
107
|
+
'When you finish, stop and write a short summary: what you changed, and what you could not verify.',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The exact first input a session would receive.
|
|
112
|
+
* @param {{initiative:string, title?:string, item:object, branch:string|null, sources:Array, judgment:string}} p
|
|
113
|
+
*/
|
|
114
|
+
function buildPrompt(p) {
|
|
115
|
+
const it = p.item || {};
|
|
116
|
+
const lines = [];
|
|
117
|
+
lines.push(`Work on initiative ${p.initiative}${p.title ? ' — ' + p.title : ''}.`);
|
|
118
|
+
lines.push(`Step: ${it.key || it.kind}${it.text && it.text !== it.key ? ' — ' + it.text : ''}.`);
|
|
119
|
+
if (it.action) lines.push(`What is needed: ${it.action}`);
|
|
120
|
+
if (it.why && it.why !== it.action) lines.push(`Why this step is open: ${it.why}`);
|
|
121
|
+
lines.push(`You are on ${p.branch ? 'branch ' + p.branch : 'the current branch'}. Read AGENTS.md, then this initiative's record, PRD and rubric in planning/ before you act.`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('Rules that do not bend:');
|
|
124
|
+
for (const r of RULES) lines.push('- ' + r);
|
|
125
|
+
if (p.sources && p.sources.length) {
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('You may read these sources for context. They are read-only: look, do not write.');
|
|
128
|
+
for (const s of p.sources) lines.push('- ' + s.label);
|
|
129
|
+
}
|
|
130
|
+
if (p.judgment) { lines.push(''); lines.push(p.judgment); }
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A short fingerprint of what the PM is shown. Go must carry it back; if anything changed, the start is refused. */
|
|
135
|
+
function planHash(parts) {
|
|
136
|
+
return crypto.createHash('sha256').update(JSON.stringify([parts.prompt, parts.branch || null, parts.wsId || null, parts.plan || null, !!parts.judgment])).digest('hex').slice(0, 16);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { actFor, annotate, refusal, sourcesFor, buildPrompt, planHash, ACT_TYPES, KNOWN_KEYS, WORK_KEYS, GATE_KEYS, RULES, SOURCE_LABEL };
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
// Who acts on a step. Acceptance, the contract approval, a document's approval and an outcome check are the PM's;
|
|
23
|
+
// every other ladder step is the agent's. This is the one place that is decided: later work reads `owner`, it
|
|
24
|
+
// does not re-decide it.
|
|
25
|
+
const PM_STEPS = new Set(['accept', 'contract', 'approved', 'outcome']);
|
|
26
|
+
const slug = (t) => String(t == null ? '' : t).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
27
|
+
|
|
28
|
+
const clip = (s, n = 200) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
29
|
+
|
|
30
|
+
function stepItem(s) {
|
|
31
|
+
const unread = s.status === 'unknown';
|
|
32
|
+
return {
|
|
33
|
+
kind: 'step', key: s.key, status: s.status, owner: PM_STEPS.has(s.key) ? 'pm' : 'agent', act: null,
|
|
34
|
+
text: unread ? `${s.key} could not be read` : s.key,
|
|
35
|
+
action: clip(s.next || s.evidence || (unread ? 'the workflow engine could not decide this step' : 'no action recorded for this step')),
|
|
36
|
+
why: clip(s.evidence),
|
|
37
|
+
blocking: !!s.blocking,
|
|
38
|
+
source: 'workflow ladder' + (s.rule ? ` — ${clip(s.rule, 120)}` : ''),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A stable id: initiative + kind + the thing it is about (the step key, the session id, the words of an open item).
|
|
43
|
+
function withIds(id, items) {
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
for (const it of items) {
|
|
46
|
+
const tail = it.kind === 'session' ? (it.sid || slug(it.text)) : it.kind === 'needs' ? slug(it.text) : it.key;
|
|
47
|
+
const base = `${id || 'initiative'}:${it.kind}:${tail}`;
|
|
48
|
+
const n = (seen.get(base) || 0) + 1; seen.set(base, n);
|
|
49
|
+
it.id = n > 1 ? `${base}:${n}` : base;
|
|
50
|
+
}
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deriveNextSteps(input) {
|
|
55
|
+
const i = input || {};
|
|
56
|
+
const row = i.ladder && i.ladder.ok && i.ladder.row ? i.ladder.row : null;
|
|
57
|
+
const steps = row && Array.isArray(row.steps) ? row.steps : [];
|
|
58
|
+
const remaining = steps.filter((s) => REMAINING.has(s.status));
|
|
59
|
+
// blocked first (the engine's own choice of `next`), the rest in ladder order
|
|
60
|
+
const ordered = [...remaining.filter((s) => s.status === 'blocked'), ...remaining.filter((s) => s.status !== 'blocked')];
|
|
61
|
+
const live = (i.sessions || []).filter((s) => s && s.state !== 'exited');
|
|
62
|
+
|
|
63
|
+
if (i.accepted) {
|
|
64
|
+
return { state: 'accepted', summary: 'Accepted by the PM — nothing remains.', items: [] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stopped = i.lifecycle && i.lifecycle.state && i.lifecycle.state !== 'active' ? i.lifecycle : null;
|
|
68
|
+
if (stopped) {
|
|
69
|
+
const items = [{
|
|
70
|
+
kind: 'reopen', key: 'reopen', status: 'pending', owner: 'pm', act: null,
|
|
71
|
+
text: `Reopen — it is ${stopped.state}`,
|
|
72
|
+
action: 'Reopen it to put it back on the board and have its steps gate again. Nothing below is required while it is stopped.',
|
|
73
|
+
why: clip(stopped.reason), source: 'lifecycle.jsonl (D70)',
|
|
74
|
+
}, ...ordered.map((s) => ({ ...stepItem(s), held: true }))];
|
|
75
|
+
items.forEach((it, n) => { it.n = n + 1; it.first = n === 0; });
|
|
76
|
+
withIds(i.id, items);
|
|
77
|
+
return {
|
|
78
|
+
state: 'stopped',
|
|
79
|
+
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.`,
|
|
80
|
+
items,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!row) {
|
|
85
|
+
const why = i.ladder && i.ladder.error ? clip(i.ladder.error, 160) : 'no initiative record, so no loop position can be derived';
|
|
86
|
+
return { state: 'unreadable', summary: `No next steps can be listed — ${why}.`, items: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The ladder's own `accept` step is the acceptance: it goes last, after everything the PM would want settled first.
|
|
90
|
+
const acceptStep = ordered.find((s) => s.key === 'accept') || null;
|
|
91
|
+
const items = ordered.filter((s) => s.key !== 'accept').map(stepItem);
|
|
92
|
+
const hasAcceptStep = steps.some((s) => s.key === 'accept');
|
|
93
|
+
for (const n of (i.needs || [])) {
|
|
94
|
+
items.push({
|
|
95
|
+
kind: 'needs', key: 'needs', status: 'pending', owner: 'pm', act: null, text: clip(n.question || '(unstated)', 160),
|
|
96
|
+
action: 'Answer or clear this open item.', why: '', source: 'needs queue',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (i.returned) {
|
|
100
|
+
items.push({
|
|
101
|
+
kind: 'returned', key: 'returned', status: 'blocked', owner: 'pm', act: null, text: 'The PM returned this at the Acceptance Gate',
|
|
102
|
+
action: 'Address the return, then request acceptance again.', why: '', source: 'acceptances.jsonl',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
for (const s of live) {
|
|
106
|
+
items.push({
|
|
107
|
+
kind: 'session', key: 'session', status: 'current', owner: 'agent', act: null, sid: s.id || null, text: `Session “${clip(s.name, 60)}” is running on it`,
|
|
108
|
+
action: 'Let it finish, or end it before accepting.', why: '', source: 'live sessions',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', owner: 'pm', text: 'PM Acceptance' });
|
|
112
|
+
else if (!hasAcceptStep) {
|
|
113
|
+
items.push({
|
|
114
|
+
kind: 'accept', key: 'accept', status: 'pending', owner: 'pm', act: null, text: 'PM Acceptance',
|
|
115
|
+
action: 'The PM names one thing verified by hand, then records the acceptance (D12). It is the PM’s and cannot be delegated.',
|
|
116
|
+
why: '', source: 'Acceptance Gate (D12)',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const engineNext = row.next && row.next.step ? row.next.step : null;
|
|
121
|
+
const firstIdx = engineNext ? Math.max(0, items.findIndex((x) => x.kind === 'step' && x.key === engineNext)) : 0;
|
|
122
|
+
items.forEach((it, n) => { it.n = n + 1; it.first = n === firstIdx; });
|
|
123
|
+
withIds(i.id, items);
|
|
124
|
+
if (!items.length) return { state: 'open', summary: 'No step is outstanding — the ladder shows every required step done. Acceptance is the PM’s.', items: [] };
|
|
125
|
+
const blockedN = items.filter((x) => x.status === 'blocked').length;
|
|
126
|
+
return {
|
|
127
|
+
state: 'open',
|
|
128
|
+
summary: `${items.length} thing${items.length === 1 ? '' : 's'} left to reach acceptance${blockedN ? `, ${blockedN} blocked` : ''}. Start with step ${firstIdx + 1}.`,
|
|
129
|
+
items,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
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,259 @@
|
|
|
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 <${COMMANDS.schedule.join('|')}> … | pmos skill <${COMMANDS.skill.join('|')}> …`);
|
|
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
|
+
// 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 };
|