create-pmos 0.38.8 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +37 -1
- package/workspace/bin/pmos.js +8 -1
- package/workspace/guide/index.md +40 -0
- package/workspace/guide/initiatives-and-next-steps.md +72 -0
- package/workspace/guide/layout.md +73 -0
- package/workspace/guide/proactive-engine.md +75 -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 +80 -0
- package/workspace/lib/brief.js +113 -0
- package/workspace/lib/claims.js +605 -0
- package/workspace/lib/doc-profiles.js +74 -0
- package/workspace/lib/initiative-files.js +37 -6
- 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 +27 -7
- package/workspace/lib/preview-kind.js +17 -2
- package/workspace/lib/proactive-cli.js +137 -0
- package/workspace/lib/proactive.js +208 -0
- package/workspace/lib/repo-watch.js +120 -0
- package/workspace/lib/schedule-cli.js +5 -2
- package/workspace/lib/secret-scan.js +28 -0
- package/workspace/lib/session-budget.js +35 -0
- package/workspace/lib/status-groups.js +53 -10
- package/workspace/lib/typesafe-settings.js +122 -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/pm/app.js +53 -18
- package/workspace/public/pm/data.js +18 -3
- package/workspace/public/pm/mobile.js +1 -1
- package/workspace/public/pm/modes-live.js +373 -0
- package/workspace/public/pm/modes.js +26 -0
- package/workspace/public/pm/next-step-ui.js +185 -0
- package/workspace/public/pm/panel-detail.js +47 -11
- package/workspace/public/pm/panel-initiatives.js +26 -4
- package/workspace/public/pm/panel-repos.js +2 -2
- package/workspace/public/pm/state.js +3 -3
- package/workspace/public/pm/status-banner.js +87 -0
- package/workspace/public/pm/tab-doc.js +66 -8
- package/workspace/server.js +562 -18
- package/workspace/skills/morning-brief.skill +43 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* doc-profiles.js — what KIND of document is this, and what must it agree with? (claims-ledger C1)
|
|
3
|
+
*
|
|
4
|
+
* A checker that treats every file the same compares things that have nothing to say about each other. So a file
|
|
5
|
+
* is classified FIRST, and the class decides what it is checked against. Order of evidence, strongest first:
|
|
6
|
+
* 1. its frontmatter `type`;
|
|
7
|
+
* 2. an initiative record that names it (prd_path, rubric_path, explainer_path, brief_path, doc_path);
|
|
8
|
+
* 3. its filename convention (<id>-prd.md, <id>-eval-rubric.md, <id>-user-stories.md, dNN-*.md …);
|
|
9
|
+
* 4. a heading signature, only for the two kinds with a distinctive one (PRD, rubric).
|
|
10
|
+
* A markdown file that matches none is UNCLASSIFIED. It still gets internal checks (its own links, its own
|
|
11
|
+
* `stale_after`) and is NEVER compared with another file. "Not classified" is an honest answer; guessing is not.
|
|
12
|
+
*
|
|
13
|
+
* `REFERENCES` states, per type, which roles the type is compared with. The extraction and the checks live in
|
|
14
|
+
* lib/claims.js and read this table, so what a type "must agree with" is written down once.
|
|
15
|
+
*/
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const IN_SCOPE_EXT = new Set(['md', 'json', 'yaml', 'yml', 'toml']);
|
|
19
|
+
|
|
20
|
+
/* Types that have a cross-reference set. Any other `type` value is classified (it has a name) but is checked
|
|
21
|
+
* internally only. */
|
|
22
|
+
const REFERENCES = {
|
|
23
|
+
Initiative: ['PRD', 'Rubric', 'Reference (explainer)', 'brief or document'],
|
|
24
|
+
PRD: ['Initiative', 'Rubric', 'decisions it cites'],
|
|
25
|
+
Rubric: ['PRD', 'Initiative'],
|
|
26
|
+
Story: ['PRD'],
|
|
27
|
+
ADR: ['the decisions file'],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const extOf = (rel) => { const b = path.posix.basename(rel).toLowerCase(); const i = b.lastIndexOf('.'); return i > 0 ? b.slice(i + 1) : ''; };
|
|
31
|
+
|
|
32
|
+
/** Which role does an initiative record give a path? `refs` maps path → [{role, id}]. */
|
|
33
|
+
const ROLE_TYPE = { prd_path: 'PRD', rubric_path: 'Rubric', explainer_path: 'Reference', brief_path: 'Reference', doc_path: 'Reference' };
|
|
34
|
+
|
|
35
|
+
const NAME_RULES = [
|
|
36
|
+
[/-eval-rubric\.md$/i, 'Rubric'],
|
|
37
|
+
[/-prd\.md$/i, 'PRD'],
|
|
38
|
+
[/-user-stories\.md$/i, 'Story'],
|
|
39
|
+
[/-surface-explainer\.md$/i, 'Reference'],
|
|
40
|
+
[/(^|\/)d\d{1,3}-[^/]+\.md$/i, 'ADR'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function headings(text) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const m of String(text).matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) out.push(m[1].toLowerCase());
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Classify one file.
|
|
51
|
+
* @param {object} a
|
|
52
|
+
* @param {string} a.rel repo-relative path with forward slashes
|
|
53
|
+
* @param {object} a.fm parsed frontmatter ({} when none)
|
|
54
|
+
* @param {string} a.text file text
|
|
55
|
+
* @param {Map} [a.named] path → [{role, id}] from initiative records
|
|
56
|
+
* @returns {{type:string|null, via:string, classified:boolean}}
|
|
57
|
+
*/
|
|
58
|
+
function classify({ rel, fm = {}, text = '', named = new Map() }) {
|
|
59
|
+
const ext = extOf(rel);
|
|
60
|
+
if (ext !== 'md') return { type: 'Config', via: 'extension', classified: true };
|
|
61
|
+
// A template is a scaffold with placeholders, not a document that can agree or disagree with anything.
|
|
62
|
+
if (/(^|\/)templates?\//i.test(rel) || /-template\.md$/i.test(rel)) return { type: 'Template', via: 'path', classified: true };
|
|
63
|
+
if (fm.type) return { type: String(fm.type), via: 'frontmatter type', classified: true };
|
|
64
|
+
const roles = named.get(rel);
|
|
65
|
+
if (roles && roles.length) return { type: ROLE_TYPE[roles[0].role] || 'Reference', via: 'named by initiative ' + roles[0].id, classified: true };
|
|
66
|
+
for (const [re, type] of NAME_RULES) if (re.test(rel)) return { type, via: 'file name', classified: true };
|
|
67
|
+
const h = headings(text);
|
|
68
|
+
const has = (s) => h.some((x) => x.includes(s));
|
|
69
|
+
if (has('requirements') && has('identity')) return { type: 'PRD', via: 'headings', classified: true };
|
|
70
|
+
if (has('dimensions') && has('sprint contract')) return { type: 'Rubric', via: 'headings', classified: true };
|
|
71
|
+
return { type: null, via: 'no evidence', classified: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { classify, extOf, headings, IN_SCOPE_EXT, REFERENCES, ROLE_TYPE };
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const { spawnSync } = require('child_process');
|
|
15
|
+
const PK = require('./preview-kind');
|
|
16
|
+
const { previewKind } = PK;
|
|
15
17
|
|
|
16
18
|
const MAX_FILES = 200;
|
|
17
19
|
const cache = new Map(); // root|id → {head, at, result}
|
|
@@ -86,13 +88,13 @@ function filesFor(root, id, opts) {
|
|
|
86
88
|
}
|
|
87
89
|
// What still exists: the checked-out tree plus the initiative's own branch tip, so a file that lives only on an
|
|
88
90
|
// 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();
|
|
91
|
+
const present = new Set(); const onHead = new Set();
|
|
90
92
|
let treeOk = false;
|
|
91
93
|
for (const ref of ['HEAD', branch].filter(Boolean)) {
|
|
92
94
|
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); }
|
|
95
|
+
if (t.ok) { treeOk = true; for (const f of t.out.split('\0')) if (f) { present.add(f); if (ref === 'HEAD') onHead.add(f); } }
|
|
94
96
|
}
|
|
95
|
-
base = { commits, via, present: treeOk ? present : null };
|
|
97
|
+
base = { commits, via, branch: branch || null, present: treeOk ? present : null, onHead: treeOk ? onHead : null };
|
|
96
98
|
cache.set(key, { head, at: Date.now(), result: base });
|
|
97
99
|
}
|
|
98
100
|
const seen = new Map();
|
|
@@ -103,9 +105,38 @@ function filesFor(root, id, opts) {
|
|
|
103
105
|
}
|
|
104
106
|
const exists = (f) => { if (base.present) return base.present.has(f); try { return fs.existsSync(path.join(root, f)); } catch { return false; } };
|
|
105
107
|
const items = [...seen.values()].filter((x) => exists(x.path))
|
|
106
|
-
|
|
108
|
+
// `where` says which copy a reader gets: `head` is in the checked-out tree; `branch` exists only on the
|
|
109
|
+
// initiative's own branch tip, so it has to be read from git, not from the working tree.
|
|
110
|
+
.map((x) => ({ path: x.path, group: groupOf(x.path), commits: x.shas.length, last: x.shas[0].slice(0, 7),
|
|
111
|
+
where: base.onHead ? (base.onHead.has(x.path) ? 'head' : 'branch') : 'head', kind: previewKind(x.path) }))
|
|
107
112
|
.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 };
|
|
113
|
+
return { ok: true, total: items.length, truncated: items.length > MAX_FILES, items: items.slice(0, MAX_FILES), via: base.via, branch: base.branch };
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Read one file as it is at `ref` (the initiative's own branch), for a file that is not in the checked-out tree.
|
|
118
|
+
* Only `initiative/<id>` and `origin/initiative/<id>` are readable, and only text under 2 MB; anything else is a
|
|
119
|
+
* reason, never a guess.
|
|
120
|
+
*/
|
|
121
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
122
|
+
function readAtRef(root, ref, rel) {
|
|
123
|
+
if (!/^(origin\/)?initiative\/[A-Za-z0-9][\w.-]*$/.test(String(ref || ''))) return { ok: false, reason: 'only an initiative branch can be read this way' };
|
|
124
|
+
const p = String(rel || '');
|
|
125
|
+
if (!p || p.startsWith('/') || p.split('/').some((seg) => seg === '..' || seg === '')) return { ok: false, reason: 'not a path inside the repository' };
|
|
126
|
+
const kind = previewKind(p);
|
|
127
|
+
// A type that is binary by nature, or opened another way (image, PDF, workbook), is refused with its reason; any
|
|
128
|
+
// other file is tried as text, like a read from the working tree (file-viewer-any-text).
|
|
129
|
+
if (kind === 'image' || kind === 'pdf' || kind === 'sheet') return { ok: false, reason: 'this kind of file is opened another way, not as text' };
|
|
130
|
+
const known = kind === 'markdown' || kind === 'text' || kind === 'html';
|
|
131
|
+
if (!known && PK.isKnownBinary(p)) return { ok: false, reason: 'this is a binary file (.' + PK.extOf(p) + ') and cannot be shown as text' };
|
|
132
|
+
const r = spawnSync('git', ['-C', root, 'show', ref + ':' + p], { encoding: 'buffer', maxBuffer: MAX_BYTES + 1024, timeout: 20000 });
|
|
133
|
+
if (r.error || r.status !== 0) return { ok: false, reason: 'git has no such file on ' + ref };
|
|
134
|
+
if (r.stdout.length > MAX_BYTES) return { ok: false, reason: 'the file is larger than 2 MB' };
|
|
135
|
+
if (r.stdout.indexOf(0) !== -1) return { ok: false, reason: 'the file is binary' };
|
|
136
|
+
let text;
|
|
137
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(r.stdout); }
|
|
138
|
+
catch { if (!known) return { ok: false, reason: 'the file is not valid UTF-8 text' }; text = r.stdout.toString('utf8'); }
|
|
139
|
+
return { ok: true, kind: known ? kind : 'text', text };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { filesFor, groupOf, namesId, readAtRef };
|
|
@@ -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 };
|
|
@@ -19,12 +19,18 @@
|
|
|
19
19
|
|
|
20
20
|
const REMAINING = new Set(['blocked', 'current', 'pending', 'unknown']);
|
|
21
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
|
+
|
|
22
28
|
const clip = (s, n = 200) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
23
29
|
|
|
24
30
|
function stepItem(s) {
|
|
25
31
|
const unread = s.status === 'unknown';
|
|
26
32
|
return {
|
|
27
|
-
kind: 'step', key: s.key, status: s.status,
|
|
33
|
+
kind: 'step', key: s.key, status: s.status, owner: PM_STEPS.has(s.key) ? 'pm' : 'agent', act: null,
|
|
28
34
|
text: unread ? `${s.key} could not be read` : s.key,
|
|
29
35
|
action: clip(s.next || s.evidence || (unread ? 'the workflow engine could not decide this step' : 'no action recorded for this step')),
|
|
30
36
|
why: clip(s.evidence),
|
|
@@ -33,6 +39,18 @@ function stepItem(s) {
|
|
|
33
39
|
};
|
|
34
40
|
}
|
|
35
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
|
+
|
|
36
54
|
function deriveNextSteps(input) {
|
|
37
55
|
const i = input || {};
|
|
38
56
|
const row = i.ladder && i.ladder.ok && i.ladder.row ? i.ladder.row : null;
|
|
@@ -49,12 +67,13 @@ function deriveNextSteps(input) {
|
|
|
49
67
|
const stopped = i.lifecycle && i.lifecycle.state && i.lifecycle.state !== 'active' ? i.lifecycle : null;
|
|
50
68
|
if (stopped) {
|
|
51
69
|
const items = [{
|
|
52
|
-
kind: 'reopen', key: 'reopen', status: 'pending',
|
|
70
|
+
kind: 'reopen', key: 'reopen', status: 'pending', owner: 'pm', act: null,
|
|
53
71
|
text: `Reopen — it is ${stopped.state}`,
|
|
54
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.',
|
|
55
73
|
why: clip(stopped.reason), source: 'lifecycle.jsonl (D70)',
|
|
56
74
|
}, ...ordered.map((s) => ({ ...stepItem(s), held: true }))];
|
|
57
75
|
items.forEach((it, n) => { it.n = n + 1; it.first = n === 0; });
|
|
76
|
+
withIds(i.id, items);
|
|
58
77
|
return {
|
|
59
78
|
state: 'stopped',
|
|
60
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.`,
|
|
@@ -73,26 +92,26 @@ function deriveNextSteps(input) {
|
|
|
73
92
|
const hasAcceptStep = steps.some((s) => s.key === 'accept');
|
|
74
93
|
for (const n of (i.needs || [])) {
|
|
75
94
|
items.push({
|
|
76
|
-
kind: 'needs', key: 'needs', status: 'pending', text: clip(n.question || '(unstated)', 160),
|
|
95
|
+
kind: 'needs', key: 'needs', status: 'pending', owner: 'pm', act: null, text: clip(n.question || '(unstated)', 160),
|
|
77
96
|
action: 'Answer or clear this open item.', why: '', source: 'needs queue',
|
|
78
97
|
});
|
|
79
98
|
}
|
|
80
99
|
if (i.returned) {
|
|
81
100
|
items.push({
|
|
82
|
-
kind: 'returned', key: 'returned', status: 'blocked', text: 'The PM returned this at the Acceptance Gate',
|
|
101
|
+
kind: 'returned', key: 'returned', status: 'blocked', owner: 'pm', act: null, text: 'The PM returned this at the Acceptance Gate',
|
|
83
102
|
action: 'Address the return, then request acceptance again.', why: '', source: 'acceptances.jsonl',
|
|
84
103
|
});
|
|
85
104
|
}
|
|
86
105
|
for (const s of live) {
|
|
87
106
|
items.push({
|
|
88
|
-
kind: 'session', key: 'session', status: 'current', text: `Session “${clip(s.name, 60)}” is running on it`,
|
|
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`,
|
|
89
108
|
action: 'Let it finish, or end it before accepting.', why: '', source: 'live sessions',
|
|
90
109
|
});
|
|
91
110
|
}
|
|
92
|
-
if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', text: 'PM Acceptance' });
|
|
111
|
+
if (acceptStep) items.push({ ...stepItem(acceptStep), kind: 'accept', owner: 'pm', text: 'PM Acceptance' });
|
|
93
112
|
else if (!hasAcceptStep) {
|
|
94
113
|
items.push({
|
|
95
|
-
kind: 'accept', key: 'accept', status: 'pending', text: 'PM Acceptance',
|
|
114
|
+
kind: 'accept', key: 'accept', status: 'pending', owner: 'pm', act: null, text: 'PM Acceptance',
|
|
96
115
|
action: 'The PM names one thing verified by hand, then records the acceptance (D12). It is the PM’s and cannot be delegated.',
|
|
97
116
|
why: '', source: 'Acceptance Gate (D12)',
|
|
98
117
|
});
|
|
@@ -101,6 +120,7 @@ function deriveNextSteps(input) {
|
|
|
101
120
|
const engineNext = row.next && row.next.step ? row.next.step : null;
|
|
102
121
|
const firstIdx = engineNext ? Math.max(0, items.findIndex((x) => x.kind === 'step' && x.key === engineNext)) : 0;
|
|
103
122
|
items.forEach((it, n) => { it.n = n + 1; it.first = n === firstIdx; });
|
|
123
|
+
withIds(i.id, items);
|
|
104
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: [] };
|
|
105
125
|
const blockedN = items.filter((x) => x.status === 'blocked').length;
|
|
106
126
|
return {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// Textual formats shown as ESCAPED source (never executed, R2). Broad on purpose — every entry
|
|
25
25
|
// is escaped text, so breadth adds no risk (PRD open-question resolved: broad allowlist).
|
|
26
26
|
const TEXT_EXT = new Set([
|
|
27
|
-
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson',
|
|
27
|
+
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson', 'jsonl', 'jsonlines', 'ldjson',
|
|
28
28
|
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'example',
|
|
29
29
|
'xml', 'rss', 'atom', // NB: .svg is classified 'image' (IMAGE_MIME, checked first) — it renders, not source
|
|
30
30
|
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat',
|
|
@@ -45,6 +45,18 @@
|
|
|
45
45
|
ico: 'image/x-icon', apng: 'image/apng',
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// File types that are binary by nature. The reader refuses these as binary WITHOUT reading them, so a
|
|
49
|
+
// type that is not on any list can still be tried as text (file-viewer-any-text) while these never are.
|
|
50
|
+
// An unlisted extension that turns out to be binary is still caught by the NUL and UTF-8 checks.
|
|
51
|
+
const BINARY_EXT = new Set([
|
|
52
|
+
'doc', 'docx', 'dot', 'dotx', 'ppt', 'pptx', 'potx', 'xls', 'xlsm', 'xltx', 'odt', 'ods', 'odp', 'pages', 'numbers', 'key',
|
|
53
|
+
'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', 'tar', '7z', 'rar', 'jar', 'war', 'apk', 'iso', 'dmg', 'pkg', 'deb', 'rpm',
|
|
54
|
+
'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'class', 'pyc', 'wasm', 'node',
|
|
55
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
56
|
+
'mp3', 'mp4', 'm4a', 'mov', 'wav', 'avi', 'mkv', 'webm', 'ogg', 'flac', 'aac',
|
|
57
|
+
'sqlite', 'sqlite3', 'db', 'mdb', 'psd', 'ai', 'heic', 'tif', 'tiff', 'raw',
|
|
58
|
+
]);
|
|
59
|
+
|
|
48
60
|
// Extension-less files that are really text (open as source).
|
|
49
61
|
const TEXT_BASENAMES = new Set(['dockerfile', 'makefile', 'license', 'licence', 'authors',
|
|
50
62
|
'notice', 'copying', 'changelog', 'procfile', 'brewfile', 'gemfile', 'rakefile', 'vagrantfile']);
|
|
@@ -77,6 +89,9 @@
|
|
|
77
89
|
return 'inert';
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
/** true when the extension names a type that is binary by nature (see BINARY_EXT). */
|
|
93
|
+
function isKnownBinary(name) { return BINARY_EXT.has(extOf(name)); }
|
|
94
|
+
|
|
80
95
|
/** image MIME for /api/asset, or null when the extension is not an allowlisted image. */
|
|
81
96
|
function imageMime(name) {
|
|
82
97
|
return IMAGE_MIME[extOf(name)] || null;
|
|
@@ -105,5 +120,5 @@
|
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
return { previewKind, imageMime, assetMime, isTextSource, isRepoRelativeSrc, extOf,
|
|
108
|
-
MARKDOWN_EXT, TEXT_EXT, HTML_EXT, IMAGE_MIME };
|
|
123
|
+
isKnownBinary, MARKDOWN_EXT, TEXT_EXT, HTML_EXT, BINARY_EXT, IMAGE_MIME };
|
|
109
124
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* proactive-cli.js — `pmos proactive …`: the terminal face of Today.
|
|
3
|
+
*
|
|
4
|
+
* A thin client of the running workspace server's own routes, like schedule-cli.js: one computation, one
|
|
5
|
+
* feedback ledger, one settings file. Nothing here writes a file itself, and nothing here starts a session,
|
|
6
|
+
* sends anything or changes an initiative. With no server running the command says so and exits 1.
|
|
7
|
+
*
|
|
8
|
+
* pmos proactive today
|
|
9
|
+
* pmos proactive scan
|
|
10
|
+
* pmos proactive settings [--enabled on|off] [--trigger NAME=on|off] [--stalled-days N] [--quiet HH:MM-HH:MM|none] [--pause-learning on|off]
|
|
11
|
+
* pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed [--note TEXT]
|
|
12
|
+
* pmos proactive feedback --missed TEXT
|
|
13
|
+
* pmos proactive brief [show]
|
|
14
|
+
* pmos proactive brief submit --file PATH
|
|
15
|
+
* pmos proactive brief schedule [--repo PATH]
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { execFileSync } = require('child_process');
|
|
20
|
+
const { parseArgs } = require('./schedule-cli');
|
|
21
|
+
|
|
22
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief'] };
|
|
23
|
+
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
24
|
+
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
25
|
+
|
|
26
|
+
async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
27
|
+
const [group, verb, ...rest] = argv;
|
|
28
|
+
const { pos, flags } = parseArgs(rest);
|
|
29
|
+
const fail = (m) => { say('✗ ' + m); return 1; };
|
|
30
|
+
if (group !== 'proactive' || !verb || flags.help || verb === 'help') { say(`usage: pmos proactive <${COMMANDS.proactive.join('|')}> …`); return verb ? 0 : 1; }
|
|
31
|
+
if (!COMMANDS.proactive.includes(verb)) return fail(`unknown command "${verb}" — use ${COMMANDS.proactive.join(', ')}`);
|
|
32
|
+
const probe = await call('GET', '/api/proactive/settings');
|
|
33
|
+
if (!probe) return fail('the workspace server is not running — start it with: pmos');
|
|
34
|
+
|
|
35
|
+
if (verb === 'today') {
|
|
36
|
+
const t = await call('GET', '/api/proactive/today');
|
|
37
|
+
if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
|
|
38
|
+
if (t.off) say('The engine is off. Nothing is scanned automatically.');
|
|
39
|
+
if (t.quiet) say('Quiet hours: no automatic scans now.');
|
|
40
|
+
for (const u of t.unreadable || []) say(`not read — ${u.name || u.repo}: ${u.error}`);
|
|
41
|
+
if (t.empty) say('Nothing is waiting on any repository that was read.');
|
|
42
|
+
for (const c of t.candidates || []) {
|
|
43
|
+
say(`${c.id} ${c.kind} ${c.owner === 'pm' ? 'yours' : "the agent's"} ${c.title}`);
|
|
44
|
+
say(` ${c.why}${c.evidence ? ' — ' + c.evidence : ''}`);
|
|
45
|
+
}
|
|
46
|
+
if (t.dismissed) say(`${t.dismissed} dismissed item(s) hidden for 7 days.`);
|
|
47
|
+
say(t.note);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (verb === 'scan') {
|
|
51
|
+
const r = await call('POST', '/api/proactive/scan', {});
|
|
52
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the scan failed');
|
|
53
|
+
say(`scanned — ${r.found} waiting, ${r.appended} new${(r.unreadable || []).length ? ', ' + r.unreadable.length + ' repository not read' : ''}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (verb === 'settings') {
|
|
57
|
+
const patch = {};
|
|
58
|
+
if ('enabled' in flags) { const v = onOff(flags.enabled, '--enabled'); if (v.err) return fail(v.err); patch.enabled = v; }
|
|
59
|
+
if ('pause-learning' in flags) { const v = onOff(flags['pause-learning'], '--pause-learning'); if (v.err) return fail(v.err); patch.pauseLearning = v; }
|
|
60
|
+
if ('trigger' in flags) {
|
|
61
|
+
const m = /^([a-z]+)=(on|off)$/.exec(flags.trigger);
|
|
62
|
+
if (!m) return fail('--trigger takes NAME=on or NAME=off, for example needs=off');
|
|
63
|
+
patch.triggers = { [m[1]]: m[2] === 'on' };
|
|
64
|
+
}
|
|
65
|
+
if ('name' in flags) patch.name = flags.name;
|
|
66
|
+
if ('brief-scoring' in flags) { const v = onOff(flags['brief-scoring'], '--brief-scoring'); if (v.err) return fail(v.err); patch.briefScoring = v; }
|
|
67
|
+
if ('brief-source' in flags) {
|
|
68
|
+
const m = /^([a-z]+)=(on|off)$/.exec(flags['brief-source']);
|
|
69
|
+
if (!m) return fail(`--brief-source takes NAME=on or NAME=off, where NAME is one of ${BRIEF_SOURCES.join(', ')}`);
|
|
70
|
+
patch.briefSources = { [m[1]]: m[2] === 'on' };
|
|
71
|
+
}
|
|
72
|
+
if ('stalled-days' in flags) patch.stalledDays = Number(flags['stalled-days']);
|
|
73
|
+
if ('quiet' in flags) {
|
|
74
|
+
if (flags.quiet === 'none') patch.quiet = null;
|
|
75
|
+
else { const m = /^(\d\d:\d\d)-(\d\d:\d\d)$/.exec(flags.quiet); if (!m) return fail('--quiet takes HH:MM-HH:MM, or none'); patch.quiet = { start: m[1], end: m[2] }; }
|
|
76
|
+
}
|
|
77
|
+
let s = probe.settings;
|
|
78
|
+
if (Object.keys(patch).length) {
|
|
79
|
+
const r = await call('POST', '/api/proactive/settings', patch);
|
|
80
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was changed.`);
|
|
81
|
+
s = r.settings;
|
|
82
|
+
}
|
|
83
|
+
say(`engine ${s.enabled ? 'on' : 'off'} · stalled after ${s.stalledDays} days · quiet ${s.quiet ? s.quiet.start + '-' + s.quiet.end : 'none'} · learning ${s.pauseLearning ? 'paused' : 'on'}`);
|
|
84
|
+
say('brief sources: ' + BRIEF_SOURCES.map((k) => `${k} ${s.briefSources && s.briefSources[k] ? 'on' : 'off'}`).join(', ') + ' · scoring ' + (s.briefScoring ? 'on' : 'off') + (s.name ? ' · name ' + s.name : ''));
|
|
85
|
+
say('watching: ' + Object.entries(s.triggers).map(([k, v]) => `${k} ${v ? 'on' : 'off'}`).join(', '));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (verb === 'brief') {
|
|
89
|
+
const sub = pos[0] || 'show';
|
|
90
|
+
if (sub === 'submit') {
|
|
91
|
+
if (!flags.file) return fail('usage: pmos proactive brief submit --file PATH');
|
|
92
|
+
let raw; try { raw = JSON.parse(fs.readFileSync(path.resolve(cwd, flags.file), 'utf8')); } catch (e) { return fail(`could not read ${flags.file} as JSON — ${e.message}`); }
|
|
93
|
+
const r = await call('POST', '/api/proactive/brief', raw);
|
|
94
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was stored.`);
|
|
95
|
+
say(`stored the brief for ${r.brief.date} — ${r.brief.priorities.length} priorities, ${r.brief.worth_asking.length} worth asking`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (sub === 'schedule') {
|
|
99
|
+
let root; try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: flags.repo ? path.resolve(cwd, flags.repo) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return fail('this is not inside a git repository — use --repo PATH'); }
|
|
100
|
+
const wl = await call('GET', '/api/workspaces');
|
|
101
|
+
const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
|
|
102
|
+
if (!hit) return fail(`${root} is not a registered workspace — open it with pmos first`);
|
|
103
|
+
const r = await call('POST', '/api/schedules/seed', { ws: hit.id, set: 'brief' });
|
|
104
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}`);
|
|
105
|
+
if (r.created.length) say(`scheduled: ${r.created[0].name} (${r.created[0].cadence} ${r.created[0].time}) — it runs only while the workspace server runs`);
|
|
106
|
+
else say(`nothing to add — ${r.kept.map((k) => k.skill + ': ' + k.why).join('; ')}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (sub !== 'show') return fail('usage: pmos proactive brief [show|submit --file PATH|schedule]');
|
|
110
|
+
const r = await call('GET', '/api/proactive/brief');
|
|
111
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
112
|
+
say(r.greeting);
|
|
113
|
+
if (!r.brief) { say('No brief yet. Schedule one with: pmos proactive brief schedule'); return 0; }
|
|
114
|
+
if (r.stale) say(`This brief is for ${r.brief.date}, not today (${r.today}). It is out of date.`);
|
|
115
|
+
say('sources: ' + Object.entries(r.brief.sources).map(([k, v]) => `${k} ${v}`).join(', '));
|
|
116
|
+
for (const e of r.brief.calendar) say(`${e.start} ${e.title}${e.with ? ' — ' + e.with : ''}`);
|
|
117
|
+
for (const x of r.brief.priorities) say(`${x.id} priority ${x.owner === 'pm' ? 'yours' : "the agent's"} ${x.title} (${x.confidence})\n ${x.why_now}`);
|
|
118
|
+
for (const x of r.brief.worth_asking) say(`${x.id} worth asking ${x.title} (${x.confidence})\n ${x.why_now}`);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
// feedback
|
|
122
|
+
const body = {};
|
|
123
|
+
if (flags.note) body.note = flags.note;
|
|
124
|
+
if (flags.missed) { body.verdict = 'missed'; body.note = flags.missed; }
|
|
125
|
+
else {
|
|
126
|
+
const [id, what] = pos;
|
|
127
|
+
if (!id || !what) return fail('usage: pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed [--note TEXT] or --missed TEXT');
|
|
128
|
+
body.candidate_id = id;
|
|
129
|
+
if (['opened', 'dismissed', 'acted', 'ignored'].includes(what)) body.event = what; else body.verdict = what;
|
|
130
|
+
}
|
|
131
|
+
const r = await call('POST', '/api/proactive/feedback', body);
|
|
132
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was recorded.`);
|
|
133
|
+
say('recorded');
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { run, COMMANDS };
|